git_stk/lock.rs
1//! A coarse advisory lock so two git-stk processes never run state-mutating
2//! commands at once. Git locks its own index and refs, but not git-stk's
3//! multi-step orchestration (snapshot, rebases, metadata, provider calls), so
4//! a concurrent run could clobber the undo snapshot or half-rewrite the stack.
5
6use std::fs;
7use std::io::{ErrorKind, Write};
8use std::path::PathBuf;
9
10use anyhow::{Context, Result, bail};
11
12use crate::git;
13
14const LOCK_FILE: &str = "stk-lock";
15
16/// Held for the duration of a mutating command; removes the lock file on drop.
17/// Outside a git repo it is a no-op, so the command surfaces its own error.
18pub struct Lock {
19 path: Option<PathBuf>,
20}
21
22impl Lock {
23 /// Take the lock for `command`, or fail if another git-stk process holds
24 /// it. Naming the command makes the contention message actionable.
25 pub fn acquire(command: &str) -> Result<Self> {
26 let Ok(path) = git::git_common_path(LOCK_FILE) else {
27 // Not a git repo: nothing to guard, and the command will report
28 // the real problem itself.
29 return Ok(Self { path: None });
30 };
31 let path = PathBuf::from(path);
32
33 // Two passes: if the first finds a lock whose holder process has died
34 // (a `kill -9`, a crash, a power loss mid-command), reclaim it and try
35 // once more. A second failure means it is genuinely held, or another
36 // run reclaimed it first.
37 for reclaim in [true, false] {
38 match fs::OpenOptions::new()
39 .write(true)
40 .create_new(true)
41 .open(&path)
42 {
43 Ok(mut file) => {
44 // Best effort: the holder line feeds the error message and
45 // the staleness check.
46 let _ = writeln!(file, "{} {command}", std::process::id());
47 return Ok(Self { path: Some(path) });
48 }
49 Err(error) if error.kind() == ErrorKind::AlreadyExists => {
50 let holder = fs::read_to_string(&path).unwrap_or_default();
51 let holder = holder.trim().to_owned();
52 if reclaim && holder_is_stale(&holder) {
53 anstream::eprintln!(
54 "{}",
55 crate::style::dim(&format!(
56 "reclaiming a stale git-stk lock; its holder ({holder}) is gone"
57 ))
58 );
59 let _ = fs::remove_file(&path);
60 continue;
61 }
62 let by = if holder.is_empty() {
63 String::new()
64 } else {
65 format!(" ({holder})")
66 };
67 bail!(
68 "another git stk operation is in progress{by}; wait for it to \
69 finish, or remove {} if it is stale",
70 path.display()
71 );
72 }
73 Err(error) => {
74 return Err(error)
75 .with_context(|| format!("failed to take the lock at {}", path.display()));
76 }
77 }
78 }
79 unreachable!("the second pass always returns or bails");
80 }
81}
82
83/// Whether the lock's recorded holder process is gone, so the lock is stale
84/// and safe to reclaim. Conservative: an unparseable holder, a live process,
85/// a process owned by another user, or a platform we cannot probe all read as
86/// "not stale" - keep the lock and let the contention message stand.
87fn holder_is_stale(holder: &str) -> bool {
88 holder
89 .split_whitespace()
90 .next()
91 .and_then(|token| token.parse::<i32>().ok())
92 .is_some_and(process_is_dead)
93}
94
95#[cfg(unix)]
96fn process_is_dead(pid: i32) -> bool {
97 if pid <= 0 {
98 return false;
99 }
100 // kill(pid, 0) sends no signal; it just probes existence. 0 means alive,
101 // ESRCH means no such process (dead), and EPERM means it exists but is
102 // owned by another user (alive, and not ours to reclaim).
103 if unsafe { libc::kill(pid, 0) } == 0 {
104 return false;
105 }
106 std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH)
107}
108
109#[cfg(windows)]
110fn process_is_dead(pid: i32) -> bool {
111 use windows_sys::Win32::Foundation::{CloseHandle, GetLastError};
112 use windows_sys::Win32::System::Threading::{GetExitCodeProcess, OpenProcess};
113
114 // Stable Win32 ABI values, kept local to minimize the windows-sys surface
115 // this depends on (and so the feature flags it needs).
116 const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
117 const STILL_ACTIVE: u32 = 259;
118 const ERROR_INVALID_PARAMETER: u32 = 87;
119
120 if pid <= 0 {
121 return false;
122 }
123
124 // SAFETY: plain Win32 calls; the handle, when opened, is closed before we
125 // return. Every uncertain outcome reads as alive, so we never reclaim a
126 // lock whose holder might still be running.
127 unsafe {
128 let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid as u32);
129 if handle.is_null() {
130 // Read GetLastError immediately, before any other call clobbers it.
131 // Only "no such process" is a confident dead; access-denied (exists,
132 // another user) and any other error mean alive.
133 return GetLastError() == ERROR_INVALID_PARAMETER;
134 }
135
136 let mut exit_code: u32 = 0;
137 let queried = GetExitCodeProcess(handle, &mut exit_code);
138 CloseHandle(handle);
139
140 // Dead only on a positively-read terminal exit code; a failed query or
141 // the still-running sentinel reads as alive.
142 queried != 0 && exit_code != STILL_ACTIVE
143 }
144}
145
146#[cfg(not(any(unix, windows)))]
147fn process_is_dead(_pid: i32) -> bool {
148 // No portable existence probe on this platform; never auto-reclaim here -
149 // the contention message tells the user how to clear a stale lock.
150 false
151}
152
153impl Drop for Lock {
154 fn drop(&mut self) {
155 if let Some(path) = &self.path {
156 let _ = fs::remove_file(path);
157 }
158 }
159}