Skip to main content

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;