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//! The marker,
15//! the inotify watch,
16//! the placement of the marked file,
17//! and the attempts and the wait
18//! are in `service_index_marker.rs`,
19//! `service_index_watch.rs`,
20//! `service_index_placement.rs`
21//! and `service_index_attempts.rs`,
22//! and are re-exported here under the names callers use.
23//!
24//! In TS you'd write (pseudocode):
25//! ```ts
26//! const lock = await acquireIndexLock(indexPath, deadline, announce); ... await lock.releaseWith(newIndex);
27//! ```
28
29use super::service_index_attempts::{Placing, wait_and_place};
30use super::service_protocol::FailureCode;
31use std::path::{Path, PathBuf};
32use std::time::Duration;
33
34pub use super::service_index_hold::IndexLock;
35pub use super::service_index_marker::{LOCK_MARKER, Marked, marker_text, parse_marker};
36pub use super::service_index_ownership::{Ownership, inspect_ownership, unsettled_locks};
37#[cfg(target_os = "linux")]
38pub use super::service_index_watch::{events_end_watch, events_name};
39pub use super::service_index_placement::{Platform, SYSTEM, placement_failure_text};
40pub use super::service_index_staging::{STAGED_INFIX, staged_path_for};
41pub use super::service_index_watch::Watch;
42use super::service_index_marker::{holder_is_gone, is_reclaimable, marker_of};
43use super::service_index_placement::{log_placement_failure, place_marked, reclaim};
44use super::service_index_staging::{refresh_age, remove_dead_staging, stage_marker_synced};
45
46/// How often the holder's notice is repeated while the wait lasts.
47pub const NOTICE_EVERY: Duration = Duration::from_secs(10);
48
49/// The environment variable that sets how often the holder's line is repeated, in whole seconds.
50pub const NOTICE_VARIABLE: &str = "CLI_GIT_WAIT_NOTICE_SECONDS";
51
52/// How often the holder's line is repeated: the variable when it holds a positive number of seconds, ten seconds otherwise.
53pub(super) fn notice_every() -> Duration {
54 return notice_interval(std::env::var(NOTICE_VARIABLE).ok().as_deref());
55}
56
57/// The interval a setting of `CLI_GIT_WAIT_NOTICE_SECONDS` stands for:
58/// a positive whole number of seconds,
59/// else ten seconds.
60/// Why:
61/// Zero would repeat the holder's line without a pause.
62pub(super) fn notice_interval(setting: Option<&str>) -> Duration {
63 return setting.and_then(|text| { return text.trim().parse::<u64>().ok(); }).filter(|seconds| { return *seconds > 0; }).map_or(NOTICE_EVERY, Duration::from_secs);
64}
65
66/// What the wait tells the clients of the batch.
67#[derive(Clone, Debug, Eq, PartialEq)]
68pub enum Announce {
69 /// A line for the person's terminal: who holds the lock and how long the wait lasts.
70 Waiting(String),
71 /// Who held the lock when the wait ran out, in words that fit "... is using this repository's index".
72 Holder(String),
73}
74
75/// Why the lock was not taken.
76#[derive(Clone, Copy, Debug, Eq, PartialEq)]
77pub struct LockFailure {
78 /// The code the clients are answered with.
79 pub code: FailureCode,
80}
81
82/// What:
83/// One attempt to take the lock, with no wait.
84/// Why:
85/// Finishing a batch a killed service left open must not hold up the next batch for the whole limit; a foreign holder, or
86/// a service holder that still runs, leaves the batch open for the next round.
87///
88/// In TS you'd write (pseudocode):
89/// ```ts
90/// tryAcquireIndexLock(index, seq)
91/// ```
92pub fn try_acquire(index: &Path, seq: u64) -> Option<IndexLock> {
93 return try_acquire_on(&SYSTEM, index, seq);
94}
95
96/// `try_acquire` with the calls of `platform`.
97pub fn try_acquire_on(platform: &Platform, index: &Path, seq: u64) -> Option<IndexLock> {
98 let lock: PathBuf = lock_path_of(index);
99 let marker: String = match marker_text(seq, platform.identity) {
100 Ok(text) => text,
101 Err(error) => {
102 eprintln!("cli-git-service: cannot take the index lock {}: this process has no birth identity to name in the lock ({error})", lock.display());
103 return None;
104 }
105 };
106 let (staged, held) = match stage_marker_synced(platform.sync, &lock, seq, marker.as_str()) {
107 Ok(made) => made,
108 Err(error) => {
109 log_placement_failure(&lock, &error);
110 return None;
111 }
112 };
113 refresh_age(&held);
114 let placed: std::io::Result<()> = match place_marked(platform, &staged, &lock) {
115 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists && is_reclaimable(&lock) => {
116 if reclaim(platform, &lock) { place_marked(platform, &staged, &lock) } else { Err(error) }
117 }
118 other => other,
119 };
120 let _removed = std::fs::remove_file(&staged);
121 if let Err(error) = &placed {
122 log_placement_failure(&lock, error);
123 return None;
124 }
125 remove_dead_staging(&lock);
126 return Some(IndexLock::placed(lock, index, marker, held, (platform.inspect, platform.release)));
127}
128
129/// The path of the lock of an index.
130pub fn lock_path_of(index: &Path) -> PathBuf {
131 let mut name = index.as_os_str().to_os_string();
132 name.push(".lock");
133 return PathBuf::from(name);
134}
135
136/// What:
137/// Takes `index.lock` by exclusive creation, waiting for a foreign holder until the limit.
138/// Why:
139/// Git's own protocol: creation fails while any process holds the lock. The wait blocks on an inotify watch set before the
140/// first attempt, so a release between the attempt and the wait is not missed, and it wakes only for the lock itself. The
141/// marker is written once, before the first attempt, so the attempts themselves create and remove nothing in the watched
142/// directory. When the limit passes, nothing was changed; the clients learn who held the lock.
143///
144/// In TS you'd write (pseudocode):
145/// ```ts
146/// await acquireIndexLock(index, marker, limit, announce)
147/// ```
148///
149/// # Errors
150///
151/// `LockFailure` with `IndexInUse` when the lock stayed held to the limit (or cannot be waited for), and with the storage
152/// code when the lock cannot be created.
153pub fn acquire(index: &Path, seq: u64, limit: Duration, announce: &dyn Fn(Announce)) -> Result<IndexLock, LockFailure> {
154 return acquire_noting_holder(index, seq, limit, announce, &mut None);
155}
156
157/// What:
158/// `acquire`,
159/// leaving the words for the holder the evidence named in `holder` while the lock was waited for.
160/// Why:
161/// The landing's log line names who held the index when a commit waited for it,
162/// also when the wait ended in a success,
163/// which `acquire` alone reports to no one (the clients are told only when the wait fails).
164///
165/// In TS you'd write (pseudocode):
166/// ```ts
167/// await acquireIndexLock(index, marker, limit, announce, (who) => { holder = who })
168/// ```
169///
170/// # Errors
171///
172/// As `acquire`.
173pub fn acquire_noting_holder(index: &Path, seq: u64, limit: Duration, announce: &dyn Fn(Announce), holder: &mut Option<String>) -> Result<IndexLock, LockFailure> {
174 return acquire_placing(&SYSTEM, index, seq, limit, announce, holder);
175}
176
177/// `acquire` with the calls of `platform`.
178///
179/// # Errors
180///
181/// As `acquire`;
182/// the log of the service says why a lock that could not be created or removed stays,
183/// and why a process with no birth identity takes none.
184pub fn acquire_on(platform: &Platform, index: &Path, seq: u64, limit: Duration, announce: &dyn Fn(Announce)) -> Result<IndexLock, LockFailure> {
185 return acquire_placing(platform, index, seq, limit, announce, &mut None);
186}
187
188/// `acquire_noting_holder` with the calls of `platform`.
189fn acquire_placing(platform: &Platform, index: &Path, seq: u64, limit: Duration, announce: &dyn Fn(Announce), holder: &mut Option<String>) -> Result<IndexLock, LockFailure> {
190 let lock: PathBuf = lock_path_of(index);
191 let directory: &Path = lock.parent().unwrap_or(Path::new("."));
192 let lock_name: std::ffi::OsString = lock.file_name().map(std::ffi::OsStr::to_os_string).unwrap_or_default();
193 let mut watch: Option<Watch> = (platform.watch)(directory);
194 let marker: String = match marker_text(seq, platform.identity) {
195 Ok(text) => text,
196 Err(error) => {
197 eprintln!("cli-git-service: cannot take the index lock {}: this process has no birth identity to name in the lock ({error})", lock.display());
198 return Err(LockFailure { code: FailureCode::Unavailable });
199 }
200 };
201 let (staged, held) = stage_marker_synced(platform.sync, &lock, seq, marker.as_str()).map_err(|error| {
202 log_placement_failure(&lock, &error);
203 return LockFailure { code: super::service_land_follow::storage_failure(&error) };
204 })?;
205 let outcome: Result<(), LockFailure> = wait_and_place(
206 platform,
207 &Placing { staged: &staged, held: &held, lock: &lock, lock_name: lock_name.as_os_str(), index, limit },
208 &mut watch,
209 announce,
210 holder,
211 );
212 let _removed = std::fs::remove_file(&staged);
213 outcome?;
214 remove_dead_staging(&lock);
215 return Ok(IndexLock::placed(lock, index, marker, held, (platform.inspect, platform.release)));
216}
217
218/// What:
219/// Removes an index lock left by a service that was killed, when it still carries the service's marker for `seq` and its
220/// holder is gone.
221/// Why:
222/// Only the marker proves the lock is ours; a foreign lock, one a Git holds, or one whose holder still runs is never touched.
223///
224/// In TS you'd write (pseudocode):
225/// ```ts
226/// removeStaleLock(index, seq)
227/// ```
228pub fn remove_stale(index: &Path, seq: u64) {
229 let lock: PathBuf = lock_path_of(index);
230 if marker_of(lock.as_path()).is_some_and(|marked| { return marked.seq == seq && holder_is_gone(marked.holder.as_ref()); }) {
231 let _removed = std::fs::remove_file(&lock);
232 }
233}
234
235/// Index lock controls stay out of the release executable.
236#[cfg(test)]
237#[path = "service_index_lock_tests.rs"]
238mod tests;