use std::fs::{remove_file, File, OpenOptions};
use std::io::{ErrorKind, Read, Write};
use std::path::{Path, PathBuf};
use std::process;
use std::time::{SystemTime, UNIX_EPOCH};
use fs4::FileExt;
use crate::{Error, Result};
const LOCKFILE_SCHEMA_VERSION: u32 = 1;
const LOCKFILE_MAGIC: &str = "emdb-lock v";
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct LockHolder {
pub schema_version: u32,
pub pid: u32,
pub acquired_at_unix_millis: u64,
pub crate_version: Option<String>,
}
#[derive(Debug)]
pub(crate) struct LockFile {
file: File,
lock_path: PathBuf,
meta_path: PathBuf,
}
impl LockFile {
pub(crate) fn acquire(db_path: &Path) -> Result<Self> {
let lock_path = lock_path_for(db_path);
let meta_path = meta_path_for(db_path);
let file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&lock_path)
.map_err(Error::LockfileError)?;
match file.try_lock_exclusive() {
Ok(()) => {
let _ = write_holder_meta(&meta_path);
Ok(Self {
file,
lock_path,
meta_path,
})
}
Err(err)
if err.kind() == ErrorKind::WouldBlock
|| err.kind() == ErrorKind::PermissionDenied =>
{
Err(Error::LockBusy { path: lock_path })
}
Err(err) => Err(Error::LockfileError(err)),
}
}
pub(crate) fn read_holder(db_path: &Path) -> Result<Option<LockHolder>> {
let meta_path = meta_path_for(db_path);
let mut file = match OpenOptions::new().read(true).open(&meta_path) {
Ok(f) => f,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(Error::LockfileError(err)),
};
let mut body = String::new();
let _bytes_read = file
.read_to_string(&mut body)
.map_err(Error::LockfileError)?;
if body.is_empty() {
return Ok(None);
}
parse_holder_body(&body).map(Some)
}
pub(crate) fn break_lock(db_path: &Path) -> Result<()> {
let lock_path = lock_path_for(db_path);
let meta_path = meta_path_for(db_path);
let mut last_err: Option<std::io::Error> = None;
for path in [lock_path, meta_path] {
match remove_file(&path) {
Ok(()) => {}
Err(err) if err.kind() == ErrorKind::NotFound => {}
Err(err) => last_err = Some(err),
}
}
match last_err {
None => Ok(()),
Some(err) => Err(Error::LockfileError(err)),
}
}
}
impl Drop for LockFile {
fn drop(&mut self) {
let _unlock_result = fs4::FileExt::unlock(&self.file);
let _remove_lock = remove_file(&self.lock_path);
let _remove_meta = remove_file(&self.meta_path);
}
}
fn lock_path_for(db_path: &Path) -> PathBuf {
let mut lock_path = db_path.as_os_str().to_owned();
lock_path.push(".lock");
PathBuf::from(lock_path)
}
fn meta_path_for(db_path: &Path) -> PathBuf {
let mut meta_path = db_path.as_os_str().to_owned();
meta_path.push(".lock-meta");
PathBuf::from(meta_path)
}
fn write_holder_meta(meta_path: &Path) -> std::io::Result<()> {
let pid = process::id();
let acquired_at_unix_millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0_u64, |d| d.as_millis().min(u64::MAX as u128) as u64);
let crate_version = env!("CARGO_PKG_VERSION");
let body = format!(
"{LOCKFILE_MAGIC}{LOCKFILE_SCHEMA_VERSION}\n\
pid={pid}\n\
acquired_at={acquired_at_unix_millis}\n\
crate_version={crate_version}\n"
);
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(meta_path)?;
file.write_all(body.as_bytes())?;
file.sync_data()?;
Ok(())
}
fn parse_holder_body(body: &str) -> Result<LockHolder> {
let mut lines = body.lines();
let header = lines.next().ok_or(Error::Corrupted {
offset: 0,
reason: "lockfile body is empty",
})?;
let schema_version_str = header
.strip_prefix(LOCKFILE_MAGIC)
.ok_or(Error::Corrupted {
offset: 0,
reason: "lockfile body has wrong magic",
})?;
let schema_version: u32 = schema_version_str.parse().map_err(|_| Error::Corrupted {
offset: 0,
reason: "lockfile body has unparseable schema version",
})?;
let mut pid: Option<u32> = None;
let mut acquired_at_unix_millis: Option<u64> = None;
let mut crate_version: Option<String> = None;
for line in lines {
let line = line.trim();
if line.is_empty() {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
match key.trim() {
"pid" => pid = value.trim().parse().ok(),
"acquired_at" => acquired_at_unix_millis = value.trim().parse().ok(),
"crate_version" => crate_version = Some(value.trim().to_string()),
_ => {} }
}
let pid = pid.ok_or(Error::Corrupted {
offset: 0,
reason: "lockfile body missing pid",
})?;
let acquired_at_unix_millis = acquired_at_unix_millis.unwrap_or(0);
Ok(LockHolder {
schema_version,
pid,
acquired_at_unix_millis,
crate_version,
})
}
#[cfg(test)]
mod tests {
use super::{
lock_path_for, meta_path_for, parse_holder_body, write_holder_meta, LockFile,
LOCKFILE_MAGIC, LOCKFILE_SCHEMA_VERSION,
};
fn tmp_path(name: &str) -> std::path::PathBuf {
let mut p = std::env::temp_dir();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0_u128, |d| d.as_nanos());
let tid = std::thread::current().id();
p.push(format!("emdb-lock-{name}-{nanos}-{tid:?}.emdb"));
p
}
#[test]
fn acquire_fresh_then_release_then_reacquire() {
let db_path = tmp_path("acquire");
let first = LockFile::acquire(db_path.as_path());
assert!(first.is_ok());
drop(first);
let second = LockFile::acquire(db_path.as_path());
assert!(second.is_ok());
drop(second);
}
#[test]
fn second_acquire_while_held_fails() {
let db_path = tmp_path("contention");
let first = LockFile::acquire(db_path.as_path());
assert!(first.is_ok());
let second = LockFile::acquire(db_path.as_path());
assert!(second.is_err());
drop(first);
}
#[test]
fn acquire_writes_holder_metadata() {
let db_path = tmp_path("metadata");
let guard = LockFile::acquire(db_path.as_path()).expect("acquire");
let holder = LockFile::read_holder(db_path.as_path())
.expect("read holder")
.expect("holder present while held");
assert_eq!(holder.schema_version, LOCKFILE_SCHEMA_VERSION);
assert_eq!(holder.pid, std::process::id());
assert!(holder.acquired_at_unix_millis > 0);
assert_eq!(
holder.crate_version.as_deref(),
Some(env!("CARGO_PKG_VERSION"))
);
drop(guard);
}
#[test]
fn read_holder_on_missing_lockfile_returns_none() {
let db_path = tmp_path("missing");
let holder = LockFile::read_holder(db_path.as_path()).expect("read missing");
assert!(holder.is_none());
}
#[test]
fn break_lock_removes_lockfile_and_metadata() {
let db_path = tmp_path("break");
let lock_path = lock_path_for(db_path.as_path());
let meta_path = meta_path_for(db_path.as_path());
let _ = std::fs::remove_file(&lock_path);
let _ = std::fs::remove_file(&meta_path);
std::fs::write(&lock_path, b"").expect("create lockfile");
write_holder_meta(meta_path.as_path()).expect("write meta");
assert!(lock_path.exists());
assert!(meta_path.exists());
LockFile::break_lock(db_path.as_path()).expect("break lock");
assert!(!lock_path.exists());
assert!(!meta_path.exists());
LockFile::break_lock(db_path.as_path()).expect("idempotent break");
}
#[test]
fn parse_tolerates_unknown_keys() {
let body = format!(
"{LOCKFILE_MAGIC}1\n\
pid=42\n\
acquired_at=1234567890\n\
crate_version=0.8.5\n\
future_field=ignored\n"
);
let holder = parse_holder_body(&body).expect("parse");
assert_eq!(holder.pid, 42);
assert_eq!(holder.acquired_at_unix_millis, 1_234_567_890);
assert_eq!(holder.crate_version.as_deref(), Some("0.8.5"));
}
#[test]
fn parse_rejects_wrong_magic() {
let body = "not-an-emdb-lock v1\npid=42\n";
assert!(parse_holder_body(body).is_err());
}
#[test]
fn parse_rejects_missing_pid() {
let body = format!("{LOCKFILE_MAGIC}1\nacquired_at=42\n");
assert!(parse_holder_body(&body).is_err());
}
}