git_policy_cli/service_index_lock.rs
1//! What:
2//! The service's hold on Git's own `index.lock` across a landing: taken first, by exclusive creation, waited for without
3//! polling when another program holds it, and released by moving the new index over the real one.
4//! Why:
5//! Git's own `commit` takes the index lock before it moves the branch and gives it up after the index is written. A landing
6//! that moved the branch first and took the lock afterwards left the index showing content a landed commit had replaced for
7//! as long as a foreign `git` held the lock, and a commit prepared from that index could land a revert. The wait for a
8//! foreign holder is event-driven: an inotify watch on the Git directory wakes the service when the lock is removed or moved
9//! away, and the only timer is the limit (`CLI_GIT_LOCK_WAIT_SECONDS`) and the repetition of the holder's notice. On
10//! platforms without inotify a held lock fails at once with the plain message. The lock carries a marker with the batch
11//! number from the moment it exists until it is removed after the new index is in place, so a restart can tell its own stale
12//! lock from a foreign one, wherever the service was killed (`doc/planning/cli-git-resident-service.md`, "Landing every
13//! command line").
14//!
15//! In TS you'd write (pseudocode):
16//! ```ts
17//! const lock = await acquireIndexLock(indexPath, deadline, announce); ... await lock.releaseWith(newIndex);
18//! ```
19
20use super::index_lock_evidence::{Verdict, classify_index_lock, gather_index_lock_evidence, proven_holder_line, proven_holder_progress_line};
21use super::service_protocol::FailureCode;
22use std::io::Write;
23use std::path::{Path, PathBuf};
24use std::time::{Duration, Instant};
25
26/// The first bytes of a lock the service holds, followed by the batch number.
27pub const LOCK_MARKER: &str = "cli-git-service ";
28
29/// How often the holder's notice is repeated while the wait lasts.
30pub const NOTICE_EVERY: Duration = Duration::from_secs(10);
31
32/// The environment variable that sets how often the holder's line is repeated, in whole seconds.
33pub const NOTICE_VARIABLE: &str = "CLI_GIT_WAIT_NOTICE_SECONDS";
34
35/// How often the holder's line is repeated: the variable when it holds a positive number of seconds, ten seconds otherwise.
36fn notice_every() -> Duration {
37 return std::env::var(NOTICE_VARIABLE).ok().and_then(|text| { return text.trim().parse::<u64>().ok(); }).filter(|seconds| { return *seconds > 0; }).map_or(NOTICE_EVERY, Duration::from_secs);
38}
39
40/// What the wait tells the clients of the batch.
41#[derive(Clone, Debug, Eq, PartialEq)]
42pub enum Announce {
43 /// A line for the person's terminal: who holds the lock and how long the wait lasts.
44 Waiting(String),
45 /// Who held the lock when the wait ran out, in words that fit "... is using this repository's index".
46 Holder(String),
47}
48
49/// Why the lock was not taken.
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub struct LockFailure {
52 /// The code the clients are answered with.
53 pub code: FailureCode,
54}
55
56/// The service's hold on one index lock; removed unless released.
57#[derive(Debug)]
58pub struct IndexLock {
59 lock: PathBuf,
60 index: PathBuf,
61 armed: bool,
62}
63
64/// An inotify watch on the directory of the lock; nothing on platforms without inotify.
65pub struct Watch {
66 #[cfg(target_os = "linux")]
67 descriptor: i32,
68}
69
70#[cfg(target_os = "linux")]
71impl Watch {
72 pub fn on(directory: &Path) -> Option<Watch> {
73 use std::os::unix::ffi::OsStrExt;
74 // SAFETY: plain system calls with valid arguments; the descriptor is closed in `Drop`.
75 let descriptor: i32 = unsafe { libc::inotify_init1(libc::IN_CLOEXEC) };
76 if descriptor < 0 {
77 return None;
78 }
79 let path = std::ffi::CString::new(directory.as_os_str().as_bytes()).ok()?;
80 // SAFETY: `path` is a valid NUL-terminated string for the call.
81 let added: i32 = unsafe { libc::inotify_add_watch(descriptor, path.as_ptr(), libc::IN_DELETE | libc::IN_MOVED_FROM) };
82 if added < 0 {
83 // SAFETY: the descriptor was just opened and nothing else owns it.
84 unsafe { libc::close(descriptor) };
85 return None;
86 }
87 return Some(Watch { descriptor });
88 }
89
90 /// Blocks until something was removed or moved away in the directory, or `timeout` passed; either way the caller looks again.
91 pub fn wait(&self, timeout: Duration) {
92 let mut entry = libc::pollfd { fd: self.descriptor, events: libc::POLLIN, revents: 0 };
93 let milliseconds: i32 = i32::try_from(timeout.as_millis()).unwrap_or(i32::MAX);
94 // SAFETY: `entry` is valid for the one descriptor named by the count.
95 let ready: i32 = unsafe { libc::poll(&raw mut entry, 1, milliseconds) };
96 if ready > 0 {
97 let mut buffer: [u8; 4096] = [0; 4096];
98 // SAFETY: the buffer is valid for its length; the events are only drained.
99 let _read = unsafe { libc::read(self.descriptor, buffer.as_mut_ptr().cast(), buffer.len()) };
100 }
101 }
102}
103
104#[cfg(target_os = "linux")]
105impl Drop for Watch {
106 fn drop(&mut self) {
107 // SAFETY: the descriptor belongs to this watch and is closed once.
108 unsafe { libc::close(self.descriptor) };
109 }
110}
111
112#[cfg(not(target_os = "linux"))]
113impl Watch {
114 pub fn on(_directory: &Path) -> Option<Watch> {
115 return None;
116 }
117
118 pub fn wait(&self, _timeout: Duration) {}
119}
120
121/// What:
122/// One attempt to take the lock, with no wait.
123/// Why:
124/// Finishing a batch a killed service left open must not hold up the next batch for the whole limit; a foreign holder leaves
125/// the batch open for the next round.
126///
127/// In TS you'd write (pseudocode):
128/// ```ts
129/// tryAcquireIndexLock(index, seq)
130/// ```
131pub fn try_acquire(index: &Path, seq: u64) -> Option<IndexLock> {
132 let lock: PathBuf = lock_path_of(index);
133 match create_marked(&lock, seq) {
134 Ok(()) => {}
135 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists && is_services_own(&lock) => {
136 let _removed = std::fs::remove_file(&lock);
137 create_marked(&lock, seq).ok()?;
138 }
139 Err(_) => return None,
140 }
141 return Some(IndexLock { lock, index: index.to_path_buf(), armed: true });
142}
143
144/// The suffix of the file the marker is written to before that file becomes the lock.
145pub const STAGED_SUFFIX: &str = ".cli-git-marker";
146
147/// The staging file of the lock at `lock`.
148pub fn staged_path_of(lock: &Path) -> PathBuf {
149 let mut name = lock.as_os_str().to_os_string();
150 name.push(STAGED_SUFFIX);
151 return PathBuf::from(name);
152}
153
154/// What:
155/// Creates `lock` already holding the service's marker for `seq`; `AlreadyExists` when any process holds it.
156/// Why:
157/// Git's lock is taken by exclusive creation, and a restart tells the service's own stale lock from a foreign one only by
158/// its marker. A lock created empty and marked afterwards stayed unmarked when the service was killed between the two, and
159/// the next instance waited it out as a foreign holder while plain Git refused too. The marker is written to a staging file
160/// first and hard-linked to the lock's name: a link fails when the name exists, as `create_new` does, and the lock never
161/// exists without its marker. One service takes a given lock at a time, so one staging name per lock suffices, and a staging
162/// file a kill left behind is overwritten by the next attempt. A filesystem without hard links falls back to exclusive
163/// creation and one write.
164///
165/// In TS you'd write (pseudocode):
166/// ```ts
167/// await writeFile(staged, marker); try { await link(staged, lock); } finally { await unlink(staged); }
168/// ```
169fn create_marked(lock: &Path, seq: u64) -> std::io::Result<()> {
170 let marker: String = format!("{LOCK_MARKER}{seq}\n");
171 let staged: PathBuf = staged_path_of(lock);
172 std::fs::write(&staged, marker.as_bytes())?;
173 let linked: std::io::Result<()> = std::fs::hard_link(&staged, lock);
174 let _removed = std::fs::remove_file(&staged);
175 return match linked {
176 Err(error) if links_unsupported(&error) => {
177 let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(lock)?;
178 file.write_all(marker.as_bytes())
179 }
180 other => other,
181 };
182}
183
184/// Whether a failed hard link means the filesystem makes none (link(2): `EPERM`), not that the lock exists or storage failed.
185fn links_unsupported(error: &std::io::Error) -> bool {
186 if error.kind() == std::io::ErrorKind::Unsupported {
187 return true;
188 }
189 #[cfg(unix)]
190 {
191 return matches!(error.raw_os_error(), Some(code) if code == libc::EPERM || code == libc::EOPNOTSUPP);
192 }
193 #[cfg(not(unix))]
194 {
195 return false;
196 }
197}
198
199/// Whether the lock at `lock` carries the marker of the service (of any batch).
200fn is_services_own(lock: &Path) -> bool {
201 let Ok(mut file) = std::fs::File::open(lock) else { return false };
202 let mut head: [u8; LOCK_MARKER.len()] = [0; LOCK_MARKER.len()];
203 return std::io::Read::read_exact(&mut file, &mut head).is_ok() && head == *LOCK_MARKER.as_bytes();
204}
205
206/// The path of the lock of an index.
207pub fn lock_path_of(index: &Path) -> PathBuf {
208 let mut name = index.as_os_str().to_os_string();
209 name.push(".lock");
210 return PathBuf::from(name);
211}
212
213/// The words for the process holding the lock, from the evidence Git's lock leaves.
214fn holder_words(pid: i64, command: Option<&str>) -> String {
215 return match command {
216 Some(name) => format!("Process {pid} ({name})"),
217 None => format!("Process {pid}"),
218 };
219}
220
221/// What the evidence says about the holder: its notice line for the person and its words, when a live holder is proven.
222fn holder_notice(index: &Path, waited: Duration, limit: Duration, first: bool) -> (Option<String>, Option<String>) {
223 let Ok(Some(evidence)) = gather_index_lock_evidence(index) else { return (None, None) };
224 let Verdict::ProvenAlive { pid, by_descriptor, command } = classify_index_lock(&evidence) else {
225 let line: String = format!(
226 "cli-git: waiting for {}, whose owner cannot be proven alive, after {} seconds; giving up after {} seconds.\n",
227 evidence.lock_path.display(),
228 waited.as_secs(),
229 limit.as_secs()
230 );
231 return (Some(line), None);
232 };
233 let line: String = if first {
234 proven_holder_line(pid, by_descriptor, command.as_deref(), evidence.lock_path.as_path(), limit.as_secs())
235 } else {
236 proven_holder_progress_line(pid, by_descriptor, command.as_deref(), evidence.lock_path.as_path(), waited.as_secs(), limit.as_secs())
237 };
238 return (Some(line), Some(holder_words(pid, command.as_deref())));
239}
240
241/// What:
242/// Takes `index.lock` by exclusive creation, waiting for a foreign holder until the limit.
243/// Why:
244/// Git's own protocol: creation fails while any process holds the lock. The wait blocks on an inotify watch set before the
245/// first attempt, so a release between the attempt and the wait is not missed. When the limit passes, nothing was changed;
246/// the clients learn who held the lock.
247///
248/// In TS you'd write (pseudocode):
249/// ```ts
250/// await acquireIndexLock(index, marker, limit, announce)
251/// ```
252///
253/// # Errors
254///
255/// `LockFailure` with `IndexInUse` when the lock stayed held to the limit (or cannot be waited for), and with the storage
256/// code when the lock cannot be created.
257pub fn acquire(index: &Path, seq: u64, limit: Duration, announce: &dyn Fn(Announce)) -> Result<IndexLock, LockFailure> {
258 let lock: PathBuf = lock_path_of(index);
259 let directory: &Path = lock.parent().unwrap_or(Path::new("."));
260 let watch: Option<Watch> = Watch::on(directory);
261 let began: Instant = Instant::now();
262 let mut first: bool = true;
263 let mut last_notice: Instant = began;
264 loop {
265 match create_marked(&lock, seq) {
266 Ok(()) => {
267 return Ok(IndexLock { lock, index: index.to_path_buf(), armed: true });
268 }
269 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
270 // Only one service runs, and it holds no lock between two landings, so a lock with its marker is a dead
271 // instance's: it is removed, and the attempt is made again at once.
272 if is_services_own(&lock) {
273 let _removed = std::fs::remove_file(&lock);
274 continue;
275 }
276 }
277 Err(error) => return Err(LockFailure { code: super::service_land_follow::storage_failure(&error) }),
278 }
279 let waited: Duration = began.elapsed();
280 let exhausted: bool = waited >= limit || watch.is_none();
281 // A wake for something else in the directory repeats nothing: the line comes at the first look and then every interval.
282 let due: bool = first || last_notice.elapsed() >= notice_every();
283 let (line, who) = if due || exhausted { holder_notice(index, waited, limit, first) } else { (None, None) };
284 if due {
285 if let Some(text) = line {
286 announce(Announce::Waiting(text));
287 }
288 first = false;
289 last_notice = Instant::now();
290 }
291 if exhausted {
292 announce(Announce::Holder(who.unwrap_or_else(|| { return String::from("Another program"); })));
293 return Err(LockFailure { code: FailureCode::IndexInUse });
294 }
295 if let Some(active) = watch.as_ref() {
296 active.wait(notice_every().saturating_sub(last_notice.elapsed()).max(Duration::from_millis(1)).min(limit.saturating_sub(waited)));
297 }
298 }
299}
300
301impl IndexLock {
302 /// What:
303 /// Installs `built`, which sits in the index's own directory, as the index by one rename, then gives the lock up.
304 /// Why:
305 /// A reader sees the old index or the new one and never a part of either, as with Git's rename of its lock. The lock,
306 /// still marked, is removed only after the rename, so a kill between the two leaves a lock the next instance knows as the
307 /// service's own. Copying the new index over the lock first left a lock without the marker for as long as the copy ran,
308 /// and a kill then left a lock no restart would take over.
309 ///
310 /// In TS you'd write (pseudocode):
311 /// ```ts
312 /// lock.releaseWith(built) // rename(built, index); unlink(lock)
313 /// ```
314 ///
315 /// # Errors
316 ///
317 /// The storage failure when the new index cannot be put in place; the lock is then removed and the old index stays.
318 pub fn release_with(mut self, built: &Path) -> Result<(), std::io::Error> {
319 // No index existed and nothing was staged when `built` is absent: there is nothing to install.
320 if built.exists() {
321 std::fs::rename(built, &self.index)?;
322 }
323 std::fs::remove_file(&self.lock)?;
324 self.armed = false;
325 return Ok(());
326 }
327}
328
329impl Drop for IndexLock {
330 fn drop(&mut self) {
331 if self.armed {
332 let _removed = std::fs::remove_file(&self.lock);
333 }
334 }
335}
336
337/// What:
338/// Removes an index lock left by a service that was killed, when it still carries the service's marker for `seq`.
339/// Why:
340/// Only the marker proves the lock is ours; a foreign lock, or one a Git holds, is never touched.
341///
342/// In TS you'd write (pseudocode):
343/// ```ts
344/// removeStaleLock(index, seq)
345/// ```
346pub fn remove_stale(index: &Path, seq: u64) {
347 let lock: PathBuf = lock_path_of(index);
348 if std::fs::read_to_string(&lock).is_ok_and(|text| { return text == format!("{LOCK_MARKER}{seq}\n"); }) {
349 let _removed = std::fs::remove_file(&lock);
350 }
351}
352
353/// Index lock controls stay out of the release executable.
354#[cfg(test)]
355#[path = "service_index_lock_tests.rs"]
356mod tests;