git_policy_cli/lock/index_lock_evidence.rs
1//! What:
2//! Evidence about a foreign `index.lock` and the verdict it supports:
3//! proven alive,
4//! dead,
5//! or evidence-free.
6//! Why:
7//! A live owner is proven by a process holding the lock open (device and inode),
8//! or by Git's
9//! `core.lockfilePid` file naming a running process that started no later than the lock
10//! changed.
11//! A PID file naming a gone process proves a dead owner.
12//! Everything else is
13//! evidence-free;
14//! an absent open holder never proves abandonment,
15//! because native
16//! `git commit` keeps `index.lock` without a descriptor through its hooks and editor
17//! (`src/index-lock/index-lock-evidence.ts`).
18//!
19//! In TS you'd write (pseudocode):
20//! ```ts
21//! const evidence = await gatherIndexLockEvidence({ realIndexPath }); classifyIndexLock(evidence);
22//! ```
23
24/// Open-holder scans.
25use crate::index_lock_holders::{HolderEvidence, scan_platform_holders};
26/// Process start times.
27use crate::process_start::{ProcessStart, resolve_process_start};
28/// Git's PID file path.
29use crate::recovery_files::lock_pid_path;
30/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
31use std::path::{Path, PathBuf};
32
33/// Longest PID file text quoted in evidence,
34/// in UTF-16 code units for ASCII text.
35/// Exempt from a citation under `LQM` (class: display cap): it limits how much text a message, log field or record
36/// names, and no command result or duration depends on it. Classed once in `doc/planning/cli-git-shipped-limits-
37/// audit.md`, "Exempt limits"; the coordinator's pick under the owner's delegation, open to the owner's veto.
38const QUOTED_TEXT_LIMIT: usize = 64;
39
40/// What:
41/// A lock's identity and change time,
42/// read without following links.
43/// Why:
44/// `LockFileMetadata`;
45/// the change time is whole milliseconds,
46/// as `Number(stat.ctimeMs)`.
47///
48/// In TS you'd write (pseudocode):
49/// ```ts
50/// type LockFileMetadata = { device: bigint; inode: bigint; ctimeMs: number };
51/// ```
52#[derive(Clone, Copy, Debug, Eq, PartialEq)]
53pub struct LockMetadata {
54 /// Device number.
55 pub device: u64,
56 /// Inode number.
57 pub inode: u64,
58 /// Status change time in whole milliseconds since the epoch.
59 pub ctime_ms: i64,
60}
61
62/// What:
63/// The process a PID file names,
64/// now.
65/// Why:
66/// `PidOwnerEvidence`.
67///
68/// In TS you'd write (pseudocode):
69/// ```ts
70/// type PidOwnerEvidence = { state: 'missing' } | { state: 'started-before-lock'; startedAtMs } | ...;
71/// ```
72#[derive(Clone, Debug, PartialEq)]
73pub enum PidOwner {
74 /// No such process runs,
75 /// or it is a zombie.
76 Missing,
77 /// A process runs that started no later than the lock changed.
78 StartedBeforeLock(
79 /// Start time in milliseconds since the epoch.
80 f64,
81 ),
82 /// A process runs that started after the lock changed:
83 /// the PID was reused.
84 StartedAfterLock(
85 /// Start time in milliseconds since the epoch.
86 f64,
87 ),
88 /// A process runs whose start time is unreadable.
89 StartUnknown(
90 /// Why the start time could not be read.
91 String,
92 ),
93}
94
95/// What:
96/// Git's `core.lockfilePid` file beside the lock.
97/// Why:
98/// `PidFileEvidence`.
99///
100/// In TS you'd write (pseudocode):
101/// ```ts
102/// type PidFileEvidence = { kind: 'absent' } | { kind: 'malformed'; text } | { kind: 'unreadable'; reason } | { kind: 'owner'; pid; owner };
103/// ```
104#[derive(Clone, Debug, PartialEq)]
105pub enum PidFile {
106 /// No PID file exists.
107 Absent,
108 /// The file does not hold `pid <n>`;
109 /// the first characters of its text.
110 Malformed(
111 /// The first characters of the text.
112 String,
113 ),
114 /// The file exists but could not be read.
115 Unreadable(
116 /// The reason the file could not be read.
117 String,
118 ),
119 /// The file names a PID.
120 Owner(
121 /// The PID the file names.
122 i64,
123 /// What the process named by the PID is now.
124 PidOwner,
125 ),
126}
127
128/// What:
129/// The evidence of one attempt.
130/// Why:
131/// `IndexLockEvidence`;
132/// the holder scan is skipped once the PID file proves a live owner.
133///
134/// In TS you'd write (pseudocode):
135/// ```ts
136/// type IndexLockEvidence = { lockPath; lock; pidFile; holders? };
137/// ```
138#[derive(Clone, Debug, PartialEq)]
139pub struct Evidence {
140 /// Lock path.
141 pub lock_path: PathBuf,
142 /// Lock identity and change time.
143 pub lock: LockMetadata,
144 /// PID file evidence.
145 pub pid_file: PidFile,
146 /// Open-holder scan,
147 /// absent when the PID file proved a live owner.
148 pub holders: Option<HolderEvidence>,
149}
150
151/// What:
152/// The classification of a foreign lock's owner.
153/// Why:
154/// `IndexLockVerdict`.
155///
156/// In TS you'd write (pseudocode):
157/// ```ts
158/// type IndexLockVerdict = { kind: 'proven-alive'; pid; source; command? } | { kind: 'dead'; pid } | { kind: 'evidence-free' };
159/// ```
160#[derive(Clone, Debug, Eq, PartialEq)]
161pub enum Verdict {
162 /// A live owner is proven;
163 /// `true` when by an open descriptor,
164 /// `false` by the PID file.
165 ProvenAlive {
166 /// Owner PID.
167 pid: i64,
168 /// Whether an open descriptor proved it (otherwise the PID file).
169 by_descriptor: bool,
170 /// Owner command name when readable.
171 command: Option<String>,
172 },
173 /// The PID file names a process that no longer runs.
174 Dead(
175 /// The PID of the process that no longer runs.
176 i64,
177 ),
178 /// Nothing proves a live owner or a dead one.
179 EvidenceFree,
180}
181
182/// What:
183/// A lock's metadata without following links,
184/// or nothing when it does not exist.
185/// Why:
186/// `readLockMetadata`.
187///
188/// In TS you'd write (pseudocode):
189/// ```ts
190/// async function readLockMetadata(lockPath: string): Promise<LockFileMetadata | typeof LOCK_ABSENT>;
191/// ```
192pub fn read_lock_metadata(lock_path: &Path) -> std::io::Result<Option<LockMetadata>> {
193 let metadata: std::fs::Metadata = match std::fs::symlink_metadata(lock_path) {
194 Ok(found) => found,
195 Err(error) if is_missing(&error) => return Ok(None),
196 Err(error) => return Err(error),
197 };
198 #[cfg(unix)]
199 {
200 /// The Unix metadata accessors,
201 /// for the device,
202 /// inode and change time.
203 use std::os::unix::fs::MetadataExt;
204 return Ok(Some(LockMetadata {
205 device: metadata.dev(),
206 inode: metadata.ino(),
207 ctime_ms: metadata.ctime() * 1000 + metadata.ctime_nsec() / 1_000_000,
208 }));
209 }
210 // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
211 #[cfg(not(unix))]
212 {
213 let changed: i64 = match metadata.modified() {
214 Ok(time) => match time.duration_since(std::time::UNIX_EPOCH) {
215 Ok(elapsed) => i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX),
216 Err(_) => 0,
217 },
218 Err(_) => 0,
219 };
220 return Ok(Some(LockMetadata { device: 0, inode: 0, ctime_ms: changed }));
221 }
222}
223
224/// What:
225/// Whether an error means the path does not exist.
226/// Why:
227/// `isMissing`:
228/// `ENOENT` and `ENOTDIR`.
229///
230/// In TS you'd write (pseudocode):
231/// ```ts
232/// error.code === 'ENOENT' || error.code === 'ENOTDIR'
233/// ```
234fn is_missing(error: &std::io::Error) -> bool {
235 return matches!(error.kind(), std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory);
236}
237
238/// What:
239/// The PID in Git's `pid <n>` text,
240/// as `read_lock_pid` reads it:
241/// trailing whitespace
242/// trimmed,
243/// then `pid ` and decimal digits of a positive safe integer.
244/// Why:
245/// `parsePidFileText`.
246///
247/// In TS you'd write (pseudocode):
248/// ```ts
249/// parsePidFileText('pid 42\n') // 42
250/// ```
251pub fn parse_pid_file_text(text: &str) -> Option<i64> {
252 let trimmed: &str = crate::js_text::trim_javascript_end(text);
253 let digits: &str = trimmed.strip_prefix("pid ")?;
254 return crate::js_text::decimal_attempt(digits);
255}
256
257/// What:
258/// The owner evidence of a PID's start against the lock's change time.
259/// Why:
260/// `ownerEvidence`:
261/// started no later than the change plus the start's resolution.
262///
263/// In TS you'd write (pseudocode):
264/// ```ts
265/// function ownerEvidence({ start, lock }): PidOwnerEvidence;
266/// ```
267pub fn owner_evidence(start: ProcessStart, lock: &LockMetadata) -> PidOwner {
268 match start {
269 ProcessStart::Missing => return PidOwner::Missing,
270 ProcessStart::Unknown(reason) => return PidOwner::StartUnknown(reason),
271 ProcessStart::Running { started_at_ms, resolution_ms } => {
272 if started_at_ms <= (lock.ctime_ms + resolution_ms) as f64 {
273 return PidOwner::StartedBeforeLock(started_at_ms);
274 }
275 return PidOwner::StartedAfterLock(started_at_ms);
276 }
277 }
278}
279
280/// What:
281/// The first characters of a text,
282/// as `slice(0, 64)` keeps them.
283/// Why:
284/// A malformed PID file is quoted,
285/// not dumped whole.
286///
287/// In TS you'd write (pseudocode):
288/// ```ts
289/// text.slice(0, 64)
290/// ```
291fn quoted_prefix(text: &str) -> String {
292 let mut prefix: String = String::new();
293 let mut units: usize = 0;
294 for character in text.chars() {
295 units += character.len_utf16();
296 if units > QUOTED_TEXT_LIMIT {
297 break;
298 }
299 prefix.push(character);
300 }
301 return prefix;
302}
303
304/// What:
305/// The PID file beside a lock and the process it names.
306/// Why:
307/// `readPidFileEvidence`;
308/// the text is read with replacement characters as `'utf8'` does.
309///
310/// In TS you'd write (pseudocode):
311/// ```ts
312/// async function readPidFileEvidence({ pidPath, lock, resolveStart }): Promise<PidFileEvidence>;
313/// ```
314pub fn read_pid_file_evidence(pid_path: &Path, lock: &LockMetadata, resolve: fn(i64) -> ProcessStart) -> PidFile {
315 let text: String = match std::fs::read(pid_path) {
316 Ok(bytes) => String::from_utf8_lossy(bytes.as_slice()).into_owned(),
317 Err(error) if is_missing(&error) => return PidFile::Absent,
318 Err(error) => return PidFile::Unreadable(error.to_string()),
319 };
320 let Some(pid) = parse_pid_file_text(text.as_str()) else {
321 return PidFile::Malformed(quoted_prefix(text.as_str()));
322 };
323 return PidFile::Owner(pid, owner_evidence(resolve(pid), lock));
324}
325
326/// What:
327/// Classify gathered evidence.
328/// Why:
329/// `classifyIndexLock`:
330/// an open holder first,
331/// then a PID file of a process that started
332/// before the lock,
333/// then one naming a gone process.
334///
335/// In TS you'd write (pseudocode):
336/// ```ts
337/// function classifyIndexLock(evidence: IndexLockEvidence): IndexLockVerdict;
338/// ```
339pub fn classify_index_lock(evidence: &Evidence) -> Verdict {
340 if let Some(scan) = &evidence.holders
341 && let Some(holder) = scan.holders.first()
342 {
343 return Verdict::ProvenAlive {
344 pid: holder.pid,
345 by_descriptor: true,
346 command: holder.command.clone(),
347 };
348 }
349 match &evidence.pid_file {
350 PidFile::Owner(pid, PidOwner::StartedBeforeLock(_)) => {
351 return Verdict::ProvenAlive {
352 pid: *pid,
353 by_descriptor: false,
354 command: None,
355 };
356 }
357 PidFile::Owner(pid, PidOwner::Missing) => return Verdict::Dead(*pid),
358 _ => return Verdict::EvidenceFree,
359 }
360}
361
362/// What:
363/// Gather one attempt's evidence with the given start-time source and holder scan.
364/// Why:
365/// `gatherIndexLockEvidence`;
366/// nothing when the lock is gone.
367///
368/// In TS you'd write (pseudocode):
369/// ```ts
370/// async function gatherIndexLockEvidence({ realIndexPath, sources }): Promise<IndexLockEvidence | typeof LOCK_ABSENT>;
371/// ```
372pub fn gather_with(
373 real_index: &Path,
374 resolve: fn(i64) -> ProcessStart,
375 scan: fn(&Path, u64, u64) -> HolderEvidence,
376) -> std::io::Result<Option<Evidence>> {
377 let mut name: std::ffi::OsString = real_index.as_os_str().to_owned();
378 name.push(".lock");
379 let lock_path: PathBuf = PathBuf::from(name);
380 let Some(lock) = read_lock_metadata(lock_path.as_path())? else {
381 return Ok(None);
382 };
383 let pid_file: PidFile = read_pid_file_evidence(lock_pid_path(real_index).as_path(), &lock, resolve);
384 if let PidFile::Owner(pid, PidOwner::StartedBeforeLock(_)) = &pid_file {
385 crate::diagnostic_log::debug(
386 "gatherIndexLockEvidence",
387 format!("PID file proves live owner {pid}").as_str(),
388 );
389 return Ok(Some(Evidence { lock_path, lock, pid_file, holders: None }));
390 }
391 let holders: HolderEvidence = scan(lock_path.as_path(), lock.device, lock.inode);
392 return Ok(Some(Evidence {
393 lock_path,
394 lock,
395 pid_file,
396 holders: Some(holders),
397 }));
398}
399
400/// What:
401/// Gather one attempt's evidence with the host's sources.
402/// Why:
403/// The production gather.
404///
405/// In TS you'd write (pseudocode):
406/// ```ts
407/// await gatherIndexLockEvidence({ realIndexPath })
408/// ```
409pub fn gather_index_lock_evidence(real_index: &Path) -> std::io::Result<Option<Evidence>> {
410 return gather_with(real_index, resolve_process_start, scan_platform_holders);
411}
412
413/// What:
414/// `PID <n>` with the command in parentheses when known.
415/// Why:
416/// Shared by the evidence and the waiting line.
417///
418/// In TS you'd write (pseudocode):
419/// ```ts
420/// `PID ${pid}${command === undefined ? '' : ` (${command})`}`
421/// ```
422fn holder_name(pid: i64, command: Option<&str>) -> String {
423 match command {
424 Some(name) => return format!("PID {pid} ({name})"),
425 None => return format!("PID {pid}"),
426 }
427}
428
429/// What:
430/// The one standard-error line naming a proven-alive holder.
431/// Why:
432/// `provenHolderLine`:
433/// the person learns what the wait is for and that it lasts as long as the holder holds the lock; a holder proven alive
434/// will let go, so no time is named at which the wait ends.
435///
436/// In TS you'd write (pseudocode):
437/// ```ts
438/// function provenHolderLine({ verdict, lockPath }): string;
439/// ```
440pub fn proven_holder_line(pid: i64, by_descriptor: bool, command: Option<&str>, lock_path: &Path) -> String {
441 let proof: &str = if by_descriptor { "an open descriptor" } else { "its Git lock PID file" };
442 return format!(
443 "cli-git: waiting for {}, which holds {} (proven by {proof}); this waits for as long as that process holds the lock \
444 and leaves the lock in place.\n",
445 holder_name(pid, command),
446 lock_path.display()
447 );
448}
449
450/// What:
451/// The line that repeats while a proven-alive holder keeps the lock.
452/// Why:
453/// Each repeat names the holder, the proof and how long the wait has lasted, so a reader of the last line alone knows what
454/// is going on.
455///
456/// In TS you'd write (pseudocode):
457/// ```ts
458/// function provenHolderProgressLine({ verdict, lockPath, waitedSeconds }): string;
459/// ```
460pub fn proven_holder_progress_line(pid: i64, by_descriptor: bool, command: Option<&str>, lock_path: &Path, waited_seconds: u64) -> String {
461 let proof: &str = if by_descriptor { "an open descriptor" } else { "its Git lock PID file" };
462 return format!(
463 "cli-git: still waiting for {}, which holds {} (proven by {proof}) after {waited_seconds} seconds; this waits for as \
464 long as that process holds the lock and leaves the lock in place.\n",
465 holder_name(pid, command),
466 lock_path.display()
467 );
468}
469
470/// What the evidence says about the lock's owner, in words that fit after "found".
471fn evidence_words(evidence: &Evidence) -> String {
472 let descriptors: String = match &evidence.holders {
473 Some(scan) if scan.partial.is_empty() => String::from("no process has the lock open"),
474 Some(scan) => format!("no process the scan could see has the lock open (the scan could not see everything: {})", scan.partial.join("; ")),
475 None => String::from("the open descriptors were not scanned"),
476 };
477 let pid_file: String = match &evidence.pid_file {
478 PidFile::Absent => String::from("no Git lock PID file beside it (`core.lockfilePid` is off for the program that made it, or it is not Git)"),
479 PidFile::Malformed(text) => format!("a Git lock PID file whose text is not `pid <number>` ({text:?})"),
480 PidFile::Unreadable(reason) => format!("a Git lock PID file that could not be read ({reason})"),
481 PidFile::Owner(pid, PidOwner::StartedAfterLock(_)) => format!("a Git lock PID file naming PID {pid}, which started after the lock was made (the number was reused)"),
482 PidFile::Owner(pid, PidOwner::StartUnknown(reason)) => format!("a Git lock PID file naming PID {pid}, whose start time could not be read ({reason})"),
483 PidFile::Owner(pid, PidOwner::Missing) => format!("a Git lock PID file naming PID {pid}, which no longer runs"),
484 PidFile::Owner(pid, PidOwner::StartedBeforeLock(_)) => format!("a Git lock PID file naming PID {pid}, which runs"),
485 };
486 return format!("{descriptors}, and {pid_file}");
487}
488
489/// What:
490/// The line that says a lock's owner cannot be proven alive, what is known about it, and what a person can do.
491/// Why:
492/// An owner nothing proves alive is waited for like one proven alive, with no bound, because the person's own `git commit`
493/// with its editor open holds the lock exactly so; the line says what was looked at and names the file to remove if no program
494/// is using the repository, which is the only case the wait cannot end by itself.
495///
496/// In TS you'd write (pseudocode):
497/// ```ts
498/// function unprovenWaitLine({ lockPath, evidence }): string;
499/// ```
500pub fn unproven_wait_line(lock_path: &Path, evidence: Option<&Evidence>) -> String {
501 let known: String = match evidence {
502 Some(found) => format!(" ({})", evidence_words(found)),
503 None => String::from(" (the evidence about its owner could not be read)"),
504 };
505 return format!(
506 "cli-git: waiting for {lock}, whose owner cannot be proven alive{known}; this waits for as long as the lock stays there. \
507 If no program is using this repository, the lock was left by a program that was killed, and removing the file {lock} \
508 lets the command run.\n",
509 lock = lock_path.display()
510 );
511}
512
513/// What:
514/// The line that names who still holds a lock when the wait for it reaches its ceiling, and what becomes of the command.
515/// Why:
516/// The owner's rule is that nothing waits for ever: at the bound the command stops and says what blocked it, with the evidence
517/// there is, and what to run again.
518///
519/// In TS you'd write (pseudocode):
520/// ```ts
521/// function ceilingLine({ lockPath, blocker, waitedSeconds, consequence }): string;
522/// ```
523pub fn ceiling_line(lock_path: &Path, blocker: &str, waited_seconds: u64, consequence: &str) -> String {
524 return format!(
525 "cli-git: {lock} is still held after {waited_seconds} seconds by {blocker}, so {consequence}; nothing was changed and the lock was \
526 left in place. Run the command again once that program is done.\n",
527 lock = lock_path.display()
528 );
529}
530
531/// What:
532/// The words for the owner of a lock that is held, from the evidence: a process proven alive, or an owner nothing proves alive
533/// with what was looked at.
534/// Why:
535/// The line at the ceiling names the blocker, whatever the proof.
536///
537/// In TS you'd write (pseudocode):
538/// ```ts
539/// function blockerWords(evidence, verdict): string;
540/// ```
541pub fn blocker_words(evidence: &Evidence) -> String {
542 return match classify_index_lock(evidence) {
543 Verdict::ProvenAlive { pid, by_descriptor, command } => {
544 let proof: &str = if by_descriptor { "an open descriptor" } else { "its Git lock PID file" };
545 format!("{} (proven by {proof})", holder_name(pid, command.as_deref()))
546 }
547 Verdict::Dead(pid) => format!("{}, which no longer runs", holder_name(pid, None)),
548 Verdict::EvidenceFree => format!("an owner nothing proves alive ({})", evidence_words(evidence)),
549 };
550}
551
552/// What:
553/// The diagnostic for a lock whose Git lock PID file names a process that no longer runs.
554/// Why:
555/// That owner can never let go, so there is nothing to wait for.
556///
557/// In TS you'd write (pseudocode):
558/// ```ts
559/// function deadOwnerDiagnostic({ lockPath, pid, consequence }): string;
560/// ```
561pub fn dead_owner_diagnostic(lock_path: &Path, pid: i64, consequence: &str) -> String {
562 return format!(
563 "cli-git: {lock} was left by PID {pid}, which no longer runs, so nothing will release it. {consequence}; nothing was \
564 changed and the lock was left in place. If no other program is using this repository, removing the file {lock} lets \
565 the command run.\n",
566 lock = lock_path.display()
567 );
568}
569
570/// Evidence controls stay out of the release executable.
571#[cfg(test)]
572#[path = "index_lock_evidence_tests.rs"]
573mod tests;