use std::fs::{File, OpenOptions};
use std::path::Path;
use fs2::FileExt;
use crate::store::{LOCK_FILE, LOCK_META_FILE, LockHolder, LockMeta, StoreError};
pub(crate) fn writer_lock_is_held(basemind_dir: &Path) -> bool {
let path = basemind_dir.join(LOCK_FILE);
let Ok(file) = OpenOptions::new().read(true).write(true).open(&path) else {
return false;
};
match file.try_lock_shared() {
Ok(()) => {
let _ = FileExt::unlock(&file);
false
}
Err(_) => true,
}
}
#[derive(Debug)]
pub enum WriterProbe {
Free,
Held { holder: Option<LockMeta> },
}
pub fn probe_writer_lock(basemind_dir: &Path) -> WriterProbe {
if writer_lock_is_held(basemind_dir) {
WriterProbe::Held {
holder: read_lock_meta(basemind_dir),
}
} else {
WriterProbe::Free
}
}
pub(crate) fn acquire_lock(basemind_dir: &Path) -> Result<File, StoreError> {
acquire_lock_as(basemind_dir, LockHolder::Maintenance)
}
pub(crate) fn acquire_lock_as(basemind_dir: &Path, holder: LockHolder) -> Result<File, StoreError> {
let path = basemind_dir.join(LOCK_FILE);
let file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&path)
.map_err(|source| StoreError::Io {
path: path.clone(),
source,
})?;
const LOCK_ATTEMPTS: u32 = 25;
const LOCK_BACKOFF: std::time::Duration = std::time::Duration::from_millis(20);
for attempt in 0..LOCK_ATTEMPTS {
match file.try_lock_exclusive() {
Ok(()) => {
write_lock_meta(basemind_dir, holder);
return Ok(file);
}
Err(_) if attempt + 1 < LOCK_ATTEMPTS => std::thread::sleep(LOCK_BACKOFF),
Err(_) => {
return Err(StoreError::Locked {
holder: read_lock_meta(basemind_dir),
path,
});
}
}
}
unreachable!("loop returns on the final attempt")
}
fn write_lock_meta(basemind_dir: &Path, holder: LockHolder) {
let acquired_unix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let meta = LockMeta {
command: holder.command().to_string(),
pid: std::process::id(),
acquired_unix,
};
let Ok(bytes) = serde_json::to_vec(&meta) else {
return;
};
let final_path = basemind_dir.join(LOCK_META_FILE);
let tmp_path = basemind_dir.join(format!("{LOCK_META_FILE}.{}.tmp", std::process::id()));
if std::fs::write(&tmp_path, &bytes).is_ok() {
let _ = std::fs::rename(&tmp_path, &final_path);
}
}
fn read_lock_meta(basemind_dir: &Path) -> Option<LockMeta> {
let bytes = std::fs::read(basemind_dir.join(LOCK_META_FILE)).ok()?;
serde_json::from_slice(&bytes).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn probe_writer_lock_reports_free_when_unlocked() {
let tmp = tempfile::tempdir().expect("tempdir");
assert!(
matches!(probe_writer_lock(tmp.path()), WriterProbe::Free),
"an untouched .basemind dir has no live writer"
);
}
#[test]
fn probe_writer_lock_names_the_live_holder() {
let tmp = tempfile::tempdir().expect("tempdir");
let guard = acquire_lock_as(tmp.path(), LockHolder::Serve).expect("acquire exclusive lock");
match probe_writer_lock(tmp.path()) {
WriterProbe::Held { holder: Some(meta) } => {
assert_eq!(meta.command, "basemind serve", "sidecar names the holder")
}
other => panic!("expected Held with named holder, got {other:?}"),
}
drop(guard);
assert!(
matches!(probe_writer_lock(tmp.path()), WriterProbe::Free),
"lock is free once the holder drops"
);
}
}