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