use anyhow::{Context, Result, bail};
use std::fs::{File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
const LOCK_FILE: &str = "index.lock";
#[derive(Debug)]
pub struct StoreLock {
_file: File,
path: PathBuf,
}
impl StoreLock {
pub fn acquire(dir: &Path) -> Result<Self> {
let path = dir.join(LOCK_FILE);
let mut file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&path)
.with_context(|| format!("opening {}", path.display()))?;
if file.try_lock().is_err() {
let mut held_by = String::new();
let _ = file.read_to_string(&mut held_by);
let held_by = held_by.trim();
let who = if held_by.is_empty() {
"another process".to_string()
} else {
held_by.to_string()
};
if daemon_holds(dir) {
bail!(
"store {} is held by a running `semlith start`, which is its writer \
for as long as it runs\n\
index through the portal or through `semlith mcp`, which forward to it, \
or stop the daemon first",
dir.display()
);
}
bail!(
"store {} is being indexed by {who}\n\
wait for it to finish, or use --store for a separate store",
dir.display()
);
}
file.seek(SeekFrom::Start(0))?;
file.set_len(0)?;
write!(file, "{}", describe_holder())?;
file.flush()?;
Ok(Self { _file: file, path })
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for StoreLock {
fn drop(&mut self) {
let _ = self.path;
}
}
fn daemon_holds(dir: &Path) -> bool {
dir.join(crate::daemon::DISCOVERY_FILE).exists()
}
fn describe_holder() -> String {
let started = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format!("pid {} (started at unix {started})", std::process::id())
}
#[cfg(test)]
mod tests {
use super::*;
fn tempdir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("semlith-lock-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn a_second_acquire_is_refused_and_names_the_holder() {
let dir = tempdir("second");
let first = StoreLock::acquire(&dir).unwrap();
let err = StoreLock::acquire(&dir).unwrap_err().to_string();
assert!(err.contains("being indexed by"), "unhelpful error: {err}");
#[cfg(unix)]
assert!(
err.contains(&format!("pid {}", std::process::id())),
"error does not name the holder: {err}"
);
#[cfg(windows)]
assert!(
err.contains("another process"),
"error should fall back to naming no one: {err}"
);
drop(first);
}
#[test]
fn releasing_lets_the_next_run_in() {
let dir = tempdir("release");
let first = StoreLock::acquire(&dir).unwrap();
drop(first);
let second = StoreLock::acquire(&dir);
assert!(second.is_ok(), "{:?}", second.err());
}
#[test]
fn a_leftover_lock_file_does_not_block() {
let dir = tempdir("stale");
std::fs::write(dir.join(LOCK_FILE), "pid 999999 (started at unix 1)").unwrap();
assert!(StoreLock::acquire(&dir).is_ok());
}
}