use std::io;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LockOutcome {
Acquired,
AlreadyOwned {
pid: u32,
},
NotAvailable,
}
#[derive(Debug)]
pub struct McpProjectLock {
run_dir: PathBuf,
stem: String,
}
impl McpProjectLock {
pub fn try_acquire(canonical: &Path) -> (LockOutcome, Option<McpProjectLock>) {
let Some(home) = crate::config::resolve_leindex_home() else {
return (LockOutcome::NotAvailable, None);
};
let run_dir = home.join("run");
Self::try_acquire_in_dir(canonical, &run_dir)
}
pub fn try_acquire_in_dir(
canonical: &Path,
run_dir: &Path,
) -> (LockOutcome, Option<McpProjectLock>) {
#[cfg(target_os = "linux")]
{
Self::try_acquire_in_dir_linux(canonical, run_dir)
}
#[cfg(not(target_os = "linux"))]
{
let _ = (canonical, run_dir);
(LockOutcome::NotAvailable, None)
}
}
#[cfg(target_os = "linux")]
fn try_acquire_in_dir_linux(
canonical: &Path,
run_dir: &Path,
) -> (LockOutcome, Option<McpProjectLock>) {
let stem = lock_stem(canonical);
if std::fs::create_dir_all(run_dir).is_err() {
return (LockOutcome::NotAvailable, None);
}
let lock_path = run_dir.join(format!("{stem}.lock"));
let start_path = run_dir.join(format!("{stem}.start"));
if let Some(pid) = read_lock_owner(&lock_path) {
if pid_is_owned(pid, &start_path) {
return (LockOutcome::AlreadyOwned { pid }, None);
}
}
match create_lock_exclusive(&lock_path) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
let owner = read_lock_owner(&lock_path);
match owner {
Some(pid) if pid_is_owned(pid, &start_path) => {
return (LockOutcome::AlreadyOwned { pid }, None);
}
Some(pid) => match crate::cli::cleanup::pid_is_alive(pid) {
Some(true) => return (LockOutcome::NotAvailable, None),
_ => {
let _ = std::fs::remove_file(&lock_path);
if create_lock_exclusive(&lock_path).is_err() {
return (LockOutcome::NotAvailable, None);
}
}
},
None => return (LockOutcome::NotAvailable, None),
}
}
Err(_) => return (LockOutcome::NotAvailable, None),
}
let my_pid = std::process::id();
if write_pid(&lock_path, my_pid).is_err() || write_start_time(&start_path, my_pid).is_err()
{
let _ = std::fs::remove_file(&lock_path);
let _ = std::fs::remove_file(&start_path);
return (LockOutcome::NotAvailable, None);
}
(
LockOutcome::Acquired,
Some(McpProjectLock {
run_dir: run_dir.to_path_buf(),
stem,
}),
)
}
pub fn release(&self) {
let lock_path = self.run_dir.join(format!("{}.lock", self.stem));
if read_lock_owner(&lock_path) == Some(std::process::id()) {
let _ = std::fs::remove_file(&lock_path);
let _ = std::fs::remove_file(self.run_dir.join(format!("{}.start", self.stem)));
}
}
}
impl Drop for McpProjectLock {
fn drop(&mut self) {
self.release();
}
}
fn lock_stem(canonical: &Path) -> String {
let hash = blake3::hash(canonical.as_os_str().as_encoded_bytes());
let mut bytes = [0u8; 8];
bytes.copy_from_slice(&hash.as_bytes()[..8]);
format!("leindex-mcp-{:016x}", u64::from_le_bytes(bytes))
}
#[cfg(target_os = "linux")]
fn pid_is_owned(pid: u32, start_path: &Path) -> bool {
let expected = std::fs::read_to_string(start_path)
.ok()
.and_then(|value| value.trim().parse::<u64>().ok());
let Some(expected) = expected else {
return false;
};
let actual = proc_start_time(pid);
if actual != Some(expected) {
return false;
}
let cmdline = std::fs::read(format!("/proc/{pid}/cmdline")).ok();
cmdline.is_some_and(|raw| {
let command = String::from_utf8_lossy(&raw);
command
.split('\0')
.any(|arg| arg.contains("leindex") || arg.contains("mcp"))
})
}
#[cfg(target_os = "linux")]
fn proc_start_time(pid: u32) -> Option<u64> {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let fields = stat.rsplit_once(") ")?.1;
fields.split_whitespace().nth(19)?.parse::<u64>().ok()
}
fn create_lock_exclusive(path: &Path) -> io::Result<()> {
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.map(|_| ())
}
fn read_lock_owner(path: &Path) -> Option<u32> {
std::fs::read_to_string(path)
.ok()
.and_then(|value| value.trim().parse::<u32>().ok())
}
fn write_pid(path: &Path, pid: u32) -> io::Result<()> {
std::fs::write(path, format!("{pid}\n"))
}
#[cfg(target_os = "linux")]
fn write_start_time(path: &Path, pid: u32) -> io::Result<()> {
let start = proc_start_time(pid)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no /proc stat for pid"))?;
std::fs::write(path, format!("{start}\n"))
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_run_dir() -> tempfile::TempDir {
tempfile::tempdir().expect("tempdir")
}
#[test]
fn test_lock_stem_is_deterministic_and_stable() {
let a = lock_stem(Path::new("/tmp/leindex/proj-alpha"));
let b = lock_stem(Path::new("/tmp/leindex/proj-alpha"));
assert_eq!(a, b);
assert!(a.starts_with("leindex-mcp-"));
let other = lock_stem(Path::new("/tmp/leindex/proj-beta"));
assert_ne!(a, other);
}
#[cfg(target_os = "linux")]
#[test]
fn test_acquire_writes_and_releases_sidecars() {
let dir = temp_run_dir();
let canonical = Path::new("/tmp/proj-a");
let (outcome, guard) = McpProjectLock::try_acquire_in_dir(canonical, dir.path());
assert_eq!(outcome, LockOutcome::Acquired);
let guard = guard.expect("guard");
let lock_path = dir.path().join(format!("{}.lock", lock_stem(canonical)));
assert!(lock_path.exists());
drop(guard);
assert!(!lock_path.exists());
}
#[cfg(target_os = "linux")]
#[test]
fn test_second_live_instance_reports_owned() {
let dir = temp_run_dir();
let canonical = Path::new("/tmp/proj-b");
let (_o1, guard1) = McpProjectLock::try_acquire_in_dir(canonical, dir.path());
assert!(guard1.is_some());
let (outcome, guard2) = McpProjectLock::try_acquire_in_dir(canonical, dir.path());
assert_eq!(
outcome,
LockOutcome::AlreadyOwned {
pid: std::process::id()
}
);
assert!(guard2.is_none());
drop(guard1);
let (outcome, _guard3) = McpProjectLock::try_acquire_in_dir(canonical, dir.path());
assert_eq!(outcome, LockOutcome::Acquired);
}
#[cfg(target_os = "linux")]
#[test]
fn test_unparseable_lock_file_is_not_stolen() {
let dir = temp_run_dir();
let canonical = Path::new("/tmp/proj-empty-lock");
let stem = lock_stem(canonical);
std::fs::write(dir.path().join(format!("{stem}.lock")), b"").unwrap();
let (outcome, guard) = McpProjectLock::try_acquire_in_dir(canonical, dir.path());
assert_eq!(outcome, LockOutcome::NotAvailable);
assert!(guard.is_none());
assert!(
dir.path().join(format!("{stem}.lock")).exists(),
"unparseable lock file must be left intact"
);
}
#[cfg(target_os = "linux")]
#[test]
fn test_release_only_removes_owned_sidecars() {
let dir = temp_run_dir();
let canonical = Path::new("/tmp/proj-stolen-lock");
let (_outcome, guard) = McpProjectLock::try_acquire_in_dir(canonical, dir.path());
let guard = guard.expect("guard");
let stem = lock_stem(canonical);
std::fs::write(dir.path().join(format!("{stem}.lock")), "12345\n").unwrap();
drop(guard);
assert!(
dir.path().join(format!("{stem}.lock")).exists(),
"foreign-owned sidecars must survive a guard drop"
);
}
#[cfg(target_os = "linux")]
#[test]
fn test_stale_lock_with_dead_pid_is_stolen() {
let dir = temp_run_dir();
let canonical = Path::new("/tmp/proj-c");
let stem = lock_stem(canonical);
let dead_pid = 1 << 22;
std::fs::write(
dir.path().join(format!("{stem}.lock")),
format!("{dead_pid}\n"),
)
.unwrap();
std::fs::write(dir.path().join(format!("{stem}.start")), "12345\n").unwrap();
let (outcome, guard) = McpProjectLock::try_acquire_in_dir(canonical, dir.path());
assert_eq!(outcome, LockOutcome::Acquired, "stale lock must be stolen");
assert!(guard.is_some());
let lock_contents =
std::fs::read_to_string(dir.path().join(format!("{stem}.lock"))).unwrap();
assert_eq!(
lock_contents.trim().parse::<u32>().unwrap(),
std::process::id()
);
}
#[cfg(target_os = "linux")]
#[test]
fn test_live_owner_without_start_sidecar_is_not_stolen() {
let dir = temp_run_dir();
let canonical = Path::new("/tmp/proj-toctou");
let stem = lock_stem(canonical);
assert_eq!(
crate::cli::cleanup::pid_is_alive(std::process::id()),
Some(true),
"test precondition: this process must be detected as a live leindex/mcp process"
);
std::fs::write(
dir.path().join(format!("{stem}.lock")),
format!("{}\n", std::process::id()),
)
.unwrap();
let (outcome, guard) = McpProjectLock::try_acquire_in_dir(canonical, dir.path());
assert_eq!(
outcome,
LockOutcome::NotAvailable,
"live owner without .start must degrade to NotAvailable, never steal"
);
assert!(guard.is_none());
let lock_path = dir.path().join(format!("{stem}.lock"));
assert!(lock_path.exists(), "live owner's lock must not be unlinked");
assert_eq!(read_lock_owner(&lock_path), Some(std::process::id()));
}
#[cfg(target_os = "linux")]
#[test]
fn test_different_projects_coexist() {
let dir = temp_run_dir();
let (o1, g1) = McpProjectLock::try_acquire_in_dir(Path::new("/tmp/proj-d1"), dir.path());
let (o2, g2) = McpProjectLock::try_acquire_in_dir(Path::new("/tmp/proj-d2"), dir.path());
assert_eq!(o1, LockOutcome::Acquired);
assert_eq!(o2, LockOutcome::Acquired);
assert!(g1.is_some());
assert!(g2.is_some());
}
#[cfg(target_os = "linux")]
#[test]
fn test_pid_is_owned_self() {
let dir = temp_run_dir();
let start_path = dir.path().join("self.start");
write_start_time(&start_path, std::process::id()).unwrap();
assert!(pid_is_owned(std::process::id(), &start_path));
assert!(!pid_is_owned(
std::process::id(),
&dir.path().join("missing.start")
));
}
#[cfg(not(target_os = "linux"))]
#[test]
fn test_non_linux_acquire_is_documented_noop() {
let dir = temp_run_dir();
let canonical = Path::new("/tmp/proj-nonlinux");
let (outcome, guard) = McpProjectLock::try_acquire_in_dir(canonical, dir.path());
assert_eq!(outcome, LockOutcome::NotAvailable);
assert!(guard.is_none());
let stem = lock_stem(canonical);
assert!(!dir.path().join(format!("{stem}.lock")).exists());
assert!(!dir.path().join(format!("{stem}.start")).exists());
}
}