1use 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
27pub const LOCK_MARKER: &str = "cli-git-service ";
29
30const MARKER_LIMIT: u64 = 512;
32
33pub const STAGED_INFIX: &str = ".cli-git-marker";
35
36pub const NOTICE_EVERY: Duration = Duration::from_secs(10);
38
39pub const NOTICE_VARIABLE: &str = "CLI_GIT_WAIT_NOTICE_SECONDS";
41
42#[cfg(test)]
44pub static PLACE_ATTEMPTS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
45
46fn 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#[derive(Clone, Debug, Eq, PartialEq)]
53pub enum Announce {
54 Waiting(String),
56 Holder(String),
58}
59
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
62pub struct LockFailure {
63 pub code: FailureCode,
65}
66
67#[derive(Debug)]
69pub struct IndexLock {
70 lock: PathBuf,
71 index: PathBuf,
72 armed: bool,
73}
74
75#[derive(Clone, Debug, Eq, PartialEq)]
77pub struct Marked {
78 pub seq: u64,
80 pub holder: Option<(i64, String)>,
82}
83
84pub 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
101pub 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
123fn 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
134fn 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
141fn is_reclaimable(lock: &Path) -> bool {
143 return marker_of(lock).is_some_and(|marked| { return holder_is_gone(marked.holder.as_ref()); });
144}
145
146pub struct Watch {
148 #[cfg(target_os = "linux")]
149 descriptor: i32,
150}
151
152#[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 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 let added: i32 = unsafe { libc::inotify_add_watch(descriptor, path.as_ptr(), libc::IN_DELETE | libc::IN_MOVED_FROM) };
199 if added < 0 {
200 unsafe { libc::close(descriptor) };
202 return None;
203 }
204 return Some(Watch { descriptor });
205 }
206
207 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 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 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 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
252pub 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#[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 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
276fn 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
292fn 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
307fn 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
348fn 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
373pub 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
400pub 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
407fn 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
415fn 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
435pub 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
466fn 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 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 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 pub fn release_with(mut self, built: &Path) -> Result<(), std::io::Error> {
532 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
550pub 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#[cfg(test)]
569#[path = "service_index_lock_tests.rs"]
570mod tests;