use anyhow::{Context, Result};
use std::path::Path;
pub(crate) struct ProjectWriteLock {
_file: std::fs::File,
identity: LockIdentity,
}
#[derive(Clone, Debug)]
pub(crate) struct LockIdentity {
lock_path: std::path::PathBuf,
#[cfg(unix)]
dev: u64,
#[cfg(unix)]
ino: u64,
}
impl LockIdentity {
fn capture(file: &std::fs::File, lock_path: &Path) -> Self {
#[cfg(unix)]
{
let (dev, ino) = descriptor_identity(file)
.or_else(|| path_identity(lock_path))
.unwrap_or((0, 0));
Self {
lock_path: lock_path.to_path_buf(),
dev,
ino,
}
}
#[cfg(not(unix))]
{
let _ = file;
Self {
lock_path: lock_path.to_path_buf(),
}
}
}
pub(crate) fn lock_file_path(&self) -> &std::path::Path {
&self.lock_path
}
pub(crate) fn still_guards(&self) -> bool {
#[cfg(unix)]
{
if self.dev == 0 && self.ino == 0 {
return true;
}
std::fs::metadata(&self.lock_path)
.map(|meta| {
use std::os::unix::fs::MetadataExt;
meta.dev() == self.dev && meta.ino() == self.ino
})
.unwrap_or(false)
}
#[cfg(not(unix))]
{
true
}
}
}
#[cfg(unix)]
fn descriptor_identity(file: &std::fs::File) -> Option<(u64, u64)> {
use std::os::fd::AsRawFd;
let mut stat: libc::stat = unsafe { std::mem::zeroed() };
if unsafe { libc::fstat(file.as_raw_fd(), &mut stat) } != 0 {
return None;
}
Some((stat.st_dev as u64, stat.st_ino as u64))
}
#[cfg(unix)]
fn path_identity(lock_path: &Path) -> Option<(u64, u64)> {
use std::os::unix::fs::MetadataExt;
let meta = std::fs::metadata(lock_path).ok()?;
Some((meta.dev(), meta.ino()))
}
const WRITE_LOCK_TIMEOUT_ENV: &str = "LEINDEX_WRITE_LOCK_TIMEOUT_MS";
const DEFAULT_WRITE_LOCK_TIMEOUT_MS: u64 = 300_000;
#[cfg(test)]
static TEST_TIMEOUT_OVERRIDE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
pub(crate) fn configured_timeout_ms() -> u64 {
#[cfg(test)]
{
let override_ms = TEST_TIMEOUT_OVERRIDE.load(std::sync::atomic::Ordering::Acquire);
if override_ms != 0 {
return override_ms;
}
}
std::env::var(WRITE_LOCK_TIMEOUT_ENV)
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DEFAULT_WRITE_LOCK_TIMEOUT_MS)
}
impl ProjectWriteLock {
pub(crate) fn acquire_bounded(storage_path: &Path) -> Result<Self> {
let timeout_ms = configured_timeout_ms();
let started = std::time::Instant::now();
loop {
match Self::try_acquire(storage_path)? {
Some(guard) => return Ok(guard),
None => {
if timeout_ms > 0 && started.elapsed().as_millis() as u64 >= timeout_ms {
anyhow::bail!(
"[transient:lock-contention] Timed out after {}ms waiting for the \
cross-process write lock at {} (another leindex writer is active). \
The index data is intact; retry shortly, or set {} to adjust the \
wait budget.",
timeout_ms,
storage_path.join("index.lock").display(),
WRITE_LOCK_TIMEOUT_ENV
);
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
}
}
}
pub(crate) fn try_acquire(storage_path: &Path) -> Result<Option<Self>> {
let lock_path = storage_path.join("index.lock");
let file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)
.with_context(|| {
format!("Failed to open write-lock file at {}", lock_path.display())
})?;
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if rc != 0 {
let err = std::io::Error::last_os_error();
let raw = err.raw_os_error();
if raw == Some(libc::EWOULDBLOCK) || raw == Some(libc::EAGAIN) {
return Ok(None);
}
anyhow::bail!(
"Failed to probe write lock at {}: {err}",
lock_path.display()
);
}
}
#[cfg(windows)]
{
match windows_lock::lock(&file, false) {
Ok(()) => {}
Err(err)
if matches!(
err.raw_os_error(),
Some(33) | Some(32) ) =>
{
return Ok(None);
}
Err(err) => {
anyhow::bail!(
"Failed to probe write lock at {}: {err}",
lock_path.display()
);
}
}
}
let identity = LockIdentity::capture(&file, &lock_path);
Ok(Some(Self {
_file: file,
identity,
}))
}
pub(crate) fn still_guards(&self) -> bool {
self.identity.still_guards()
}
pub(crate) fn identity(&self) -> LockIdentity {
self.identity.clone()
}
}
impl Drop for ProjectWriteLock {
fn drop(&mut self) {
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
let _ = unsafe { libc::flock(self._file.as_raw_fd(), libc::LOCK_UN) };
}
#[cfg(windows)]
{
windows_lock::unlock(&self._file);
}
}
}
#[cfg(windows)]
mod windows_lock {
use std::fs::File;
use std::os::windows::io::AsRawHandle;
#[repr(C)]
#[derive(Default)]
struct Overlapped {
internal: usize,
internal_high: usize,
offset_low: u32,
offset_high: u32,
event: usize,
}
const LOCKFILE_EXCLUSIVE_LOCK: u32 = 0x0000_0002;
const LOCKFILE_FAIL_IMMEDIATELY: u32 = 0x0000_0001;
unsafe extern "system" {
fn LockFileEx(
handle: usize,
flags: u32,
reserved: u32,
len_low: u32,
len_high: u32,
overlapped: *mut Overlapped,
) -> i32;
fn UnlockFileEx(
handle: usize,
reserved: u32,
len_low: u32,
len_high: u32,
overlapped: *mut Overlapped,
) -> i32;
}
pub(super) fn lock(file: &File, blocking: bool) -> std::io::Result<()> {
let handle = file.as_raw_handle() as usize;
let mut overlapped = Overlapped::default();
let mut flags = LOCKFILE_EXCLUSIVE_LOCK;
if !blocking {
flags |= LOCKFILE_FAIL_IMMEDIATELY;
}
let ok = unsafe { LockFileEx(handle, flags, 0, 1, 0, &mut overlapped) };
if ok == 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(())
}
}
pub(super) fn unlock(file: &File) {
let handle = file.as_raw_handle() as usize;
let mut overlapped = Overlapped::default();
let _ = unsafe { UnlockFileEx(handle, 0, 1, 0, &mut overlapped) };
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_acquire_bounded_times_out_behind_wedged_holder() {
let dir = tempfile::tempdir().expect("tempdir");
let storage = dir.path();
let holder = ProjectWriteLock::try_acquire(storage)
.expect("first acquire")
.expect("lock free");
TEST_TIMEOUT_OVERRIDE.store(300, std::sync::atomic::Ordering::Release);
let (tx, rx) = std::sync::mpsc::channel();
let storage_owned = storage.to_path_buf();
let waiter = std::thread::spawn(move || {
let result = ProjectWriteLock::acquire_bounded(&storage_owned);
let _ = tx.send(result.map(|_| ()).map_err(|e| e.to_string()));
});
let outcome = rx
.recv_timeout(std::time::Duration::from_secs(10))
.expect("acquire_bounded must terminate within the timeout");
TEST_TIMEOUT_OVERRIDE.store(0, std::sync::atomic::Ordering::Release);
let message = outcome.expect_err("lock is held; must time out");
assert!(
message.contains("[transient:lock-contention]"),
"timeout error must carry the transient sentinel so the registry \
does not brick the generation; got: {message}"
);
assert!(
message.contains("index.lock"),
"timeout error must name the lock path for remediation; got: {message}"
);
waiter.join().expect("waiter thread");
drop(holder);
TEST_TIMEOUT_OVERRIDE.store(2_000, std::sync::atomic::Ordering::Release);
let reacquired = ProjectWriteLock::acquire_bounded(storage);
TEST_TIMEOUT_OVERRIDE.store(0, std::sync::atomic::Ordering::Release);
reacquired.expect("lock freed; bounded acquire must succeed");
}
#[test]
fn test_still_guards_detects_lock_file_replacement() {
let dir = tempfile::tempdir().expect("tempdir");
let storage = dir.path();
let guard = ProjectWriteLock::try_acquire(storage)
.expect("acquire")
.expect("lock free");
assert!(guard.still_guards(), "freshly held lock must guard");
std::fs::remove_file(storage.join("index.lock")).expect("remove lock file");
assert!(
!guard.still_guards(),
"deleted lock file must invalidate the guard"
);
std::fs::write(storage.join("index.lock"), b"").expect("recreate lock file");
assert!(
!guard.still_guards(),
"re-created lock file is a different inode; guard must be invalidated"
);
}
}