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