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 itself is removed or
9//!  moved 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 and the holding process from the moment it exists until it is removed after the new index is in place, so a
12//!  restart can tell its own stale lock from a foreign one wherever the service was killed, and never takes over a lock
13//!  whose holder still runs (`doc/planning/cli-git-resident-service.md`, "Landing every 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::process_identity::{current_birth_identity, process_birth_identity};
22use super::service_protocol::FailureCode;
23use std::io::{Read, Write};
24use std::path::{Path, PathBuf};
25use std::time::{Duration, Instant};
26
27/// The first bytes of a lock the service holds, followed by the batch number, the holding process and its birth identity.
28pub const LOCK_MARKER: &str = "cli-git-service ";
29
30/// The longest marker this reader looks at.
31const MARKER_LIMIT: u64 = 512;
32
33/// What follows the lock's file name in the name of a staging file: the marker is written there before it becomes the lock.
34pub const STAGED_INFIX: &str = ".cli-git-marker";
35
36/// How often the holder's notice is repeated while the wait lasts.
37pub const NOTICE_EVERY: Duration = Duration::from_secs(10);
38
39/// The environment variable that sets how often the holder's line is repeated, in whole seconds.
40pub const NOTICE_VARIABLE: &str = "CLI_GIT_WAIT_NOTICE_SECONDS";
41
42/// How many times this process tried to place a lock; tests read it to show a wait does not wake itself.
43#[cfg(test)]
44pub static PLACE_ATTEMPTS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
45
46/// How often the holder's line is repeated: the variable when it holds a positive number of seconds, ten seconds otherwise.
47fn notice_every() -> Duration {
48    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);
49}
50
51/// What the wait tells the clients of the batch.
52#[derive(Clone, Debug, Eq, PartialEq)]
53pub enum Announce {
54    /// A line for the person's terminal: who holds the lock and how long the wait lasts.
55    Waiting(String),
56    /// Who held the lock when the wait ran out, in words that fit "... is using this repository's index".
57    Holder(String),
58}
59
60/// Why the lock was not taken.
61#[derive(Clone, Copy, Debug, Eq, PartialEq)]
62pub struct LockFailure {
63    /// The code the clients are answered with.
64    pub code: FailureCode,
65}
66
67/// The service's hold on one index lock; removed unless released.
68#[derive(Debug)]
69pub struct IndexLock {
70    lock: PathBuf,
71    index: PathBuf,
72    armed: bool,
73}
74
75/// What a service marker names.
76#[derive(Clone, Debug, Eq, PartialEq)]
77pub struct Marked {
78    /// The batch the lock was taken for.
79    pub seq: u64,
80    /// The holding process and its birth identity; none in a marker a release before this one wrote.
81    pub holder: Option<(i64, String)>,
82}
83
84/// What:
85///  The marker this process writes for batch `seq`: the batch, the process and its birth identity, one line.
86/// Why:
87///   A restart reclaims a marked lock only when that process is gone, so a slow but live holder keeps its lock; without a
88///  birth identity the marker names no holder and reads as a dead instance's, as markers did before.
89///
90/// In TS you'd write (pseudocode):
91/// ```ts
92/// `cli-git-service ${seq} ${process.pid} ${birth}\n`
93/// ```
94pub fn marker_text(seq: u64) -> String {
95    return match current_birth_identity() {
96        Ok(birth) => format!("{LOCK_MARKER}{seq} {} {birth}\n", std::process::id()),
97        Err(_) => format!("{LOCK_MARKER}{seq}\n"),
98    };
99}
100
101/// What:
102///  The batch and holder a lock's text names, or nothing when it is not a service marker.
103/// Why:
104///   A marker without a holder is one a release before this one wrote; one with a malformed holder is not the service's.
105///
106/// In TS you'd write (pseudocode):
107/// ```ts
108/// parseMarker(text) // { seq, holder?: [pid, birth] } | undefined
109/// ```
110pub fn parse_marker(text: &str) -> Option<Marked> {
111    let rest: &str = text.strip_prefix(LOCK_MARKER)?;
112    let line: &str = rest.strip_suffix('\n').unwrap_or(rest);
113    let mut parts = line.splitn(3, ' ');
114    let seq: u64 = parts.next()?.parse::<u64>().ok()?;
115    let holder: Option<(i64, String)> = match (parts.next(), parts.next()) {
116        (None, None) => None,
117        (Some(pid), Some(birth)) if !birth.is_empty() => Some((pid.parse::<i64>().ok()?, birth.to_string())),
118        _ => return None,
119    };
120    return Some(Marked { seq, holder });
121}
122
123/// Whether the process a marker names is gone: no process under that number, or another one born since. A question that
124///  cannot be answered keeps the lock, which is the safe side.
125fn holder_is_gone(holder: Option<&(i64, String)>) -> bool {
126    let Some((pid, birth)) = holder else { return true };
127    return match process_birth_identity(*pid) {
128        Ok(None) => true,
129        Ok(Some(now)) => now != *birth,
130        Err(_) => false,
131    };
132}
133
134/// The marker of the file at `path`, when it holds one.
135fn marker_of(path: &Path) -> Option<Marked> {
136    let mut text: String = String::new();
137    std::fs::File::open(path).ok()?.take(MARKER_LIMIT).read_to_string(&mut text).ok()?;
138    return parse_marker(text.as_str());
139}
140
141/// Whether the lock at `lock` is the service's and its holder is gone, so it may be removed.
142fn is_reclaimable(lock: &Path) -> bool {
143    return marker_of(lock).is_some_and(|marked| { return holder_is_gone(marked.holder.as_ref()); });
144}
145
146/// An inotify watch on the directory of the lock; nothing on platforms without inotify.
147pub struct Watch {
148    #[cfg(target_os = "linux")]
149    descriptor: i32,
150}
151
152/// What:
153///  Whether a buffer of inotify events names `wanted`, or reports an overflow that may have hidden it.
154/// Why:
155///   The watch covers the whole Git directory, where Git and the service create and remove other files all the time; only
156///  the lock's own removal is a reason to try again, so a wait is not woken by its own staging file or a neighbour's.
157///
158/// In TS you'd write (pseudocode):
159/// ```ts
160/// events.some((event) => event.overflow || event.name === wanted)
161/// ```
162#[cfg(target_os = "linux")]
163pub fn events_name(buffer: &[u8], wanted: &[u8]) -> bool {
164    let header: usize = std::mem::size_of::<libc::inotify_event>();
165    let mut at: usize = 0;
166    while at + header <= buffer.len() {
167        let field = |offset: usize| -> u32 {
168            let mut bytes: [u8; 4] = [0; 4];
169            bytes.copy_from_slice(&buffer[at + offset..at + offset + 4]);
170            return u32::from_ne_bytes(bytes);
171        };
172        if field(4) & libc::IN_Q_OVERFLOW != 0 {
173            return true;
174        }
175        let length: usize = usize::try_from(field(12)).unwrap_or(usize::MAX);
176        let start: usize = at + header;
177        let end: usize = start.saturating_add(length).min(buffer.len());
178        let name: &[u8] = buffer[start..end].split(|byte| { return *byte == 0; }).next().unwrap_or(&[]);
179        if name == wanted {
180            return true;
181        }
182        at = start.saturating_add(length);
183    }
184    return false;
185}
186
187#[cfg(target_os = "linux")]
188impl Watch {
189    pub fn on(directory: &Path) -> Option<Watch> {
190        use std::os::unix::ffi::OsStrExt;
191        // SAFETY: plain system calls with valid arguments; the descriptor is closed in `Drop`.
192        let descriptor: i32 = unsafe { libc::inotify_init1(libc::IN_CLOEXEC) };
193        if descriptor < 0 {
194            return None;
195        }
196        let path = std::ffi::CString::new(directory.as_os_str().as_bytes()).ok()?;
197        // SAFETY: `path` is a valid NUL-terminated string for the call.
198        let added: i32 = unsafe { libc::inotify_add_watch(descriptor, path.as_ptr(), libc::IN_DELETE | libc::IN_MOVED_FROM) };
199        if added < 0 {
200            // SAFETY: the descriptor was just opened and nothing else owns it.
201            unsafe { libc::close(descriptor) };
202            return None;
203        }
204        return Some(Watch { descriptor });
205    }
206
207    /// Blocks until the file `name` in the directory was removed or moved away, or `timeout` passed; either way the caller
208    ///  looks again. Events for other files are drained and the wait goes on.
209    pub fn wait(&self, timeout: Duration, name: &std::ffi::OsStr) {
210        use std::os::unix::ffi::OsStrExt;
211        let deadline: Instant = Instant::now() + timeout;
212        loop {
213            let left: Duration = deadline.saturating_duration_since(Instant::now());
214            if left.is_zero() {
215                return;
216            }
217            let mut entry = libc::pollfd { fd: self.descriptor, events: libc::POLLIN, revents: 0 };
218            let milliseconds: i32 = i32::try_from(left.as_millis()).unwrap_or(i32::MAX).max(1);
219            // SAFETY: `entry` is valid for the one descriptor named by the count.
220            let ready: i32 = unsafe { libc::poll(&raw mut entry, 1, milliseconds) };
221            if ready <= 0 {
222                return;
223            }
224            let mut buffer: [u8; 4096] = [0; 4096];
225            // SAFETY: the buffer is valid for its length.
226            let read: isize = unsafe { libc::read(self.descriptor, buffer.as_mut_ptr().cast(), buffer.len()) };
227            let Ok(count) = usize::try_from(read) else { return };
228            if count == 0 || events_name(&buffer[..count], name.as_bytes()) {
229                return;
230            }
231        }
232    }
233}
234
235#[cfg(target_os = "linux")]
236impl Drop for Watch {
237    fn drop(&mut self) {
238        // SAFETY: the descriptor belongs to this watch and is closed once.
239        unsafe { libc::close(self.descriptor) };
240    }
241}
242
243#[cfg(not(target_os = "linux"))]
244impl Watch {
245    pub fn on(_directory: &Path) -> Option<Watch> {
246        return None;
247    }
248
249    pub fn wait(&self, _timeout: Duration, _name: &std::ffi::OsStr) {}
250}
251
252/// The staging file this process writes for batch `seq` of the lock at `lock`: named by process and batch, so no attempt
253///  ever opens a name another attempt left, which may be the lock's own file.
254pub fn staged_path_for(lock: &Path, seq: u64) -> PathBuf {
255    let mut name = lock.as_os_str().to_os_string();
256    name.push(format!("{STAGED_INFIX}-{}-{seq}", std::process::id()));
257    return PathBuf::from(name);
258}
259
260/// Renames `from` to `to` unless `to` exists: one step that is as exclusive as `create_new` and leaves no second name.
261#[cfg(target_os = "linux")]
262fn rename_noreplace(from: &Path, to: &Path) -> std::io::Result<()> {
263    use std::os::unix::ffi::OsStrExt;
264    let source = std::ffi::CString::new(from.as_os_str().as_bytes()).map_err(std::io::Error::other)?;
265    let target = std::ffi::CString::new(to.as_os_str().as_bytes()).map_err(std::io::Error::other)?;
266    // SAFETY: both strings are valid NUL-terminated paths for the call.
267    let done: i32 = unsafe { libc::renameat2(libc::AT_FDCWD, source.as_ptr(), libc::AT_FDCWD, target.as_ptr(), libc::RENAME_NOREPLACE) };
268    return if done == 0 { Ok(()) } else { Err(std::io::Error::last_os_error()) };
269}
270
271#[cfg(not(target_os = "linux"))]
272fn rename_noreplace(_from: &Path, _to: &Path) -> std::io::Result<()> {
273    return Err(std::io::Error::from(std::io::ErrorKind::Unsupported));
274}
275
276/// Whether a failed rename without replacing means the filesystem cannot do one (renameat2(2): `EINVAL`), not that the lock
277///  exists or storage failed.
278fn rename_unsupported(error: &std::io::Error) -> bool {
279    if error.kind() == std::io::ErrorKind::Unsupported {
280        return true;
281    }
282    #[cfg(unix)]
283    {
284        return matches!(error.raw_os_error(), Some(code) if code == libc::EINVAL || code == libc::ENOSYS || code == libc::EOPNOTSUPP);
285    }
286    #[cfg(not(unix))]
287    {
288        return false;
289    }
290}
291
292/// Whether a failed hard link means the filesystem makes none (link(2): `EPERM`), not that the lock exists or storage failed.
293fn links_unsupported(error: &std::io::Error) -> bool {
294    if error.kind() == std::io::ErrorKind::Unsupported {
295        return true;
296    }
297    #[cfg(unix)]
298    {
299        return matches!(error.raw_os_error(), Some(code) if code == libc::EPERM || code == libc::EOPNOTSUPP);
300    }
301    #[cfg(not(unix))]
302    {
303        return false;
304    }
305}
306
307/// What:
308///  Makes the marked `staged` file the lock at `lock`; `AlreadyExists` when any process holds it, and `staged` then stays for
309///  the next attempt.
310/// Why:
311///   Git's lock is taken by exclusive creation, and a restart tells the service's own stale lock from a foreign one only by
312///  its marker, so the lock must never exist without it. A rename that does not replace is exclusive and atomic and leaves
313///  no second name; where the filesystem has none, a hard link is exclusive too and its staging name is removed after; where
314///  it has neither, exclusive creation and one write remain, a failed write removes the lock again, and only a kill between
315///  the creation and the write can leave an unmarked lock, which no restart takes over.
316///
317/// In TS you'd write (pseudocode):
318/// ```ts
319/// await renameNoReplace(staged, lock) ?? await link(staged, lock) ?? await createAndWrite(lock, marker)
320/// ```
321fn place_marked(staged: &Path, lock: &Path) -> std::io::Result<()> {
322    #[cfg(test)]
323    PLACE_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
324    match rename_noreplace(staged, lock) {
325        Ok(()) => return Ok(()),
326        Err(error) if !rename_unsupported(&error) => return Err(error),
327        Err(_) => {}
328    }
329    match std::fs::hard_link(staged, lock) {
330        Ok(()) => {
331            let _removed = std::fs::remove_file(staged);
332            return Ok(());
333        }
334        Err(error) if !links_unsupported(&error) => return Err(error),
335        Err(_) => {}
336    }
337    let marker: Vec<u8> = std::fs::read(staged)?;
338    let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(lock)?;
339    if let Err(error) = file.write_all(marker.as_slice()) {
340        drop(file);
341        let _removed = std::fs::remove_file(lock);
342        return Err(error);
343    }
344    let _removed = std::fs::remove_file(staged);
345    return Ok(());
346}
347
348/// What:
349///  Removes the staging files of the lock at `lock` that a killed attempt left: the fixed name an earlier release used, and
350///  any whose writer is gone.
351/// Why:
352///   A kill before the rename leaves the staging file behind; a live writer's file is its own and stays.
353fn remove_dead_staging(lock: &Path) {
354    use std::os::unix::ffi::OsStrExt;
355    let (Some(directory), Some(file)) = (lock.parent(), lock.file_name()) else { return };
356    let mut prefix: Vec<u8> = file.as_bytes().to_vec();
357    prefix.extend_from_slice(STAGED_INFIX.as_bytes());
358    let Ok(entries) = std::fs::read_dir(directory) else { return };
359    for entry in entries.flatten() {
360        let name = entry.file_name();
361        let bytes: &[u8] = name.as_bytes();
362        if !bytes.starts_with(prefix.as_slice()) {
363            continue;
364        }
365        let path: PathBuf = entry.path();
366        let gone: bool = bytes.len() == prefix.len() || marker_of(path.as_path()).is_none_or(|marked| { return holder_is_gone(marked.holder.as_ref()); });
367        if gone {
368            let _removed = std::fs::remove_file(path);
369        }
370    }
371}
372
373/// What:
374///  One attempt to take the lock, with no wait.
375/// Why:
376///   Finishing a batch a killed service left open must not hold up the next batch for the whole limit; a foreign holder, or
377///  a service holder that still runs, leaves the batch open for the next round.
378///
379/// In TS you'd write (pseudocode):
380/// ```ts
381/// tryAcquireIndexLock(index, seq)
382/// ```
383pub fn try_acquire(index: &Path, seq: u64) -> Option<IndexLock> {
384    let lock: PathBuf = lock_path_of(index);
385    let staged: PathBuf = staged_path_for(&lock, seq);
386    std::fs::write(&staged, marker_text(seq).as_bytes()).ok()?;
387    let placed: std::io::Result<()> = match place_marked(&staged, &lock) {
388        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists && is_reclaimable(&lock) => {
389            let _removed = std::fs::remove_file(&lock);
390            place_marked(&staged, &lock)
391        }
392        other => other,
393    };
394    let _removed = std::fs::remove_file(&staged);
395    placed.ok()?;
396    remove_dead_staging(&lock);
397    return Some(IndexLock { lock, index: index.to_path_buf(), armed: true });
398}
399
400/// The path of the lock of an index.
401pub fn lock_path_of(index: &Path) -> PathBuf {
402    let mut name = index.as_os_str().to_os_string();
403    name.push(".lock");
404    return PathBuf::from(name);
405}
406
407/// The words for the process holding the lock, from the evidence Git's lock leaves.
408fn holder_words(pid: i64, command: Option<&str>) -> String {
409    return match command {
410        Some(name) => format!("Process {pid} ({name})"),
411        None => format!("Process {pid}"),
412    };
413}
414
415/// What the evidence says about the holder: its notice line for the person and its words, when a live holder is proven.
416fn holder_notice(index: &Path, waited: Duration, limit: Duration, first: bool) -> (Option<String>, Option<String>) {
417    let Ok(Some(evidence)) = gather_index_lock_evidence(index) else { return (None, None) };
418    let Verdict::ProvenAlive { pid, by_descriptor, command } = classify_index_lock(&evidence) else {
419        let line: String = format!(
420            "cli-git: waiting for {}, whose owner cannot be proven alive, after {} seconds; giving up after {} seconds.\n",
421            evidence.lock_path.display(),
422            waited.as_secs(),
423            limit.as_secs()
424        );
425        return (Some(line), None);
426    };
427    let line: String = if first {
428        proven_holder_line(pid, by_descriptor, command.as_deref(), evidence.lock_path.as_path(), limit.as_secs())
429    } else {
430        proven_holder_progress_line(pid, by_descriptor, command.as_deref(), evidence.lock_path.as_path(), waited.as_secs(), limit.as_secs())
431    };
432    return (Some(line), Some(holder_words(pid, command.as_deref())));
433}
434
435/// What:
436///  Takes `index.lock` by exclusive creation, waiting for a foreign holder until the limit.
437/// Why:
438///   Git's own protocol: creation fails while any process holds the lock. The wait blocks on an inotify watch set before the
439///  first attempt, so a release between the attempt and the wait is not missed, and it wakes only for the lock itself. The
440///  marker is written once, before the first attempt, so the attempts themselves create and remove nothing in the watched
441///  directory. When the limit passes, nothing was changed; the clients learn who held the lock.
442///
443/// In TS you'd write (pseudocode):
444/// ```ts
445/// await acquireIndexLock(index, marker, limit, announce)
446/// ```
447///
448/// # Errors
449///
450/// `LockFailure` with `IndexInUse` when the lock stayed held to the limit (or cannot be waited for), and with the storage
451///  code when the lock cannot be created.
452pub fn acquire(index: &Path, seq: u64, limit: Duration, announce: &dyn Fn(Announce)) -> Result<IndexLock, LockFailure> {
453    let lock: PathBuf = lock_path_of(index);
454    let directory: &Path = lock.parent().unwrap_or(Path::new("."));
455    let lock_name: std::ffi::OsString = lock.file_name().map(std::ffi::OsStr::to_os_string).unwrap_or_default();
456    let watch: Option<Watch> = Watch::on(directory);
457    let staged: PathBuf = staged_path_for(&lock, seq);
458    std::fs::write(&staged, marker_text(seq).as_bytes()).map_err(|error| { return LockFailure { code: super::service_land_follow::storage_failure(&error) }; })?;
459    let outcome: Result<(), LockFailure> = wait_and_place(&staged, &lock, lock_name.as_os_str(), index, limit, watch.as_ref(), announce);
460    let _removed = std::fs::remove_file(&staged);
461    outcome?;
462    remove_dead_staging(&lock);
463    return Ok(IndexLock { lock, index: index.to_path_buf(), armed: true });
464}
465
466/// The attempts and the wait of `acquire`, with the staging file already written.
467fn wait_and_place(
468    staged: &Path,
469    lock: &Path,
470    lock_name: &std::ffi::OsStr,
471    index: &Path,
472    limit: Duration,
473    watch: Option<&Watch>,
474    announce: &dyn Fn(Announce),
475) -> Result<(), LockFailure> {
476    let began: Instant = Instant::now();
477    let mut first: bool = true;
478    let mut last_notice: Instant = began;
479    loop {
480        match place_marked(staged, lock) {
481            Ok(()) => return Ok(()),
482            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
483                // A lock with the service's marker whose holder is gone is a dead instance's: it is removed, and the
484                //  attempt is made again at once. A live holder of any release is waited for like a foreign one.
485                if is_reclaimable(lock) {
486                    let _removed = std::fs::remove_file(lock);
487                    continue;
488                }
489            }
490            Err(error) => return Err(LockFailure { code: super::service_land_follow::storage_failure(&error) }),
491        }
492        let waited: Duration = began.elapsed();
493        let exhausted: bool = waited >= limit || watch.is_none();
494        // A wake for something else in the directory repeats nothing: the line comes at the first look and then every interval.
495        let due: bool = first || last_notice.elapsed() >= notice_every();
496        let (line, who) = if due || exhausted { holder_notice(index, waited, limit, first) } else { (None, None) };
497        if due {
498            if let Some(text) = line {
499                announce(Announce::Waiting(text));
500            }
501            first = false;
502            last_notice = Instant::now();
503        }
504        if exhausted {
505            announce(Announce::Holder(who.unwrap_or_else(|| { return String::from("Another program"); })));
506            return Err(LockFailure { code: FailureCode::IndexInUse });
507        }
508        if let Some(active) = watch {
509            active.wait(notice_every().saturating_sub(last_notice.elapsed()).max(Duration::from_millis(1)).min(limit.saturating_sub(waited)), lock_name);
510        }
511    }
512}
513
514impl IndexLock {
515    /// What:
516    ///  Installs `built`, which sits in the index's own directory, as the index by one rename, then gives the lock up.
517    /// Why:
518    ///   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,
519    ///  still marked, is removed only after the rename, so a kill between the two leaves a lock the next instance knows as the
520    ///  service's own. Copying the new index over the lock first left a lock without the marker for as long as the copy ran,
521    ///  and a kill then left a lock no restart would take over.
522    ///
523    /// In TS you'd write (pseudocode):
524    /// ```ts
525    /// lock.releaseWith(built) // rename(built, index); unlink(lock)
526    /// ```
527    ///
528    /// # Errors
529    ///
530    /// The storage failure when the new index cannot be put in place; the lock is then removed and the old index stays.
531    pub fn release_with(mut self, built: &Path) -> Result<(), std::io::Error> {
532        // No index existed and nothing was staged when `built` is absent: there is nothing to install.
533        if built.exists() {
534            std::fs::rename(built, &self.index)?;
535        }
536        std::fs::remove_file(&self.lock)?;
537        self.armed = false;
538        return Ok(());
539    }
540}
541
542impl Drop for IndexLock {
543    fn drop(&mut self) {
544        if self.armed {
545            let _removed = std::fs::remove_file(&self.lock);
546        }
547    }
548}
549
550/// What:
551///  Removes an index lock left by a service that was killed, when it still carries the service's marker for `seq` and its
552///  holder is gone.
553/// Why:
554///   Only the marker proves the lock is ours; a foreign lock, one a Git holds, or one whose holder still runs is never touched.
555///
556/// In TS you'd write (pseudocode):
557/// ```ts
558/// removeStaleLock(index, seq)
559/// ```
560pub fn remove_stale(index: &Path, seq: u64) {
561    let lock: PathBuf = lock_path_of(index);
562    if marker_of(lock.as_path()).is_some_and(|marked| { return marked.seq == seq && holder_is_gone(marked.holder.as_ref()); }) {
563        let _removed = std::fs::remove_file(&lock);
564    }
565}
566
567/// Index lock controls stay out of the release executable.
568#[cfg(test)]
569#[path = "service_index_lock_tests.rs"]
570mod tests;