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,
meta_path: PathBuf,
}
const ACQUIRE_ATTEMPTS: usize = 8;
const MAX_HOLDER_META_BYTES: u64 = 4096;
impl LockFile {
pub(crate) fn acquire(db_path: &Path) -> Result<Self> {
let db_path = crate::data_dir::canonical_database_path(db_path)?;
let lock_path = lock_path_for(&db_path);
let meta_path = meta_path_for(&db_path);
for _ in 0..ACQUIRE_ATTEMPTS {
let file = crate::private_fs::open_or_create_private_file(&lock_path)
.map_err(Error::LockfileError)?;
match file.try_lock_exclusive() {
Ok(()) => {}
Err(err) if is_lock_contention(&err) => {
return Err(Error::LockBusy { path: lock_path });
}
Err(err) => return Err(Error::LockfileError(err)),
}
if !lock_path_names_locked_file(&lock_path)? {
drop(file);
continue;
}
let _ = write_holder_meta(&meta_path);
return Ok(Self { file, meta_path });
}
Err(Error::LockBusy { path: lock_path })
}
pub(crate) fn read_holder(db_path: &Path) -> Result<Option<LockHolder>> {
let db_path = crate::data_dir::canonical_database_path(db_path)?;
let meta_path = meta_path_for(&db_path);
let 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 raw = Vec::new();
let _bytes_read = file
.take(MAX_HOLDER_META_BYTES)
.read_to_end(&mut raw)
.map_err(Error::LockfileError)?;
if raw.is_empty() {
return Ok(None);
}
let body = std::str::from_utf8(&raw).map_err(|_| Error::Corrupted {
offset: 0,
reason: "lockfile body is not UTF-8",
})?;
parse_holder_body(body).map(Some)
}
pub(crate) fn break_lock(db_path: &Path) -> Result<()> {
let db_path = crate::data_dir::canonical_database_path(db_path)?;
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 _remove_meta = remove_file(&self.meta_path);
let _unlock_result = fs4::FileExt::unlock(&self.file);
}
}
fn is_lock_contention(err: &std::io::Error) -> bool {
const ERROR_LOCK_VIOLATION: i32 = 33;
err.kind() == ErrorKind::WouldBlock
|| err.kind() == ErrorKind::PermissionDenied
|| (cfg!(windows) && err.raw_os_error() == Some(ERROR_LOCK_VIOLATION))
}
fn lock_path_names_locked_file(lock_path: &Path) -> Result<bool> {
let probe = match OpenOptions::new().read(true).open(lock_path) {
Ok(f) => f,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(false),
Err(err) => return Err(Error::LockfileError(err)),
};
match probe.try_lock_exclusive() {
Ok(()) => {
let _unlock = fs4::FileExt::unlock(&probe);
Ok(false)
}
Err(_) => Ok(true),
}
}
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"
);
match remove_file(meta_path) {
Ok(()) => {}
Err(err) if err.kind() == ErrorKind::NotFound => {}
Err(err) => return Err(err),
}
let mut file = crate::private_fs::create_new_private_file(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, lock_path_names_locked_file, meta_path_for, parse_holder_body,
write_holder_meta, LockFile, LOCKFILE_MAGIC, LOCKFILE_SCHEMA_VERSION,
MAX_HOLDER_META_BYTES,
};
use fs4::FileExt;
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 test_drop_keeps_lock_file_and_removes_holder_meta() {
let db_path = tmp_path("drop-keeps");
let lock_path = lock_path_for(db_path.as_path());
let meta_path = meta_path_for(db_path.as_path());
let guard = LockFile::acquire(db_path.as_path()).expect("acquire");
assert!(meta_path.exists());
drop(guard);
assert!(lock_path.exists(), "lock file must not be unlinked on drop");
assert!(!meta_path.exists(), "holder metadata removed on drop");
let again = LockFile::acquire(db_path.as_path()).expect("reacquire");
drop(again);
let _ = std::fs::remove_file(&lock_path);
}
#[test]
fn test_lock_path_probe_detects_unlinked_and_replaced_file() {
let db_path = tmp_path("probe");
let lock_path = lock_path_for(db_path.as_path());
let held = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.expect("create lock file");
held.try_lock_exclusive().expect("lock");
assert!(lock_path_names_locked_file(&lock_path).expect("probe"));
std::fs::remove_file(&lock_path).expect("unlink");
assert!(!lock_path_names_locked_file(&lock_path).expect("probe"));
std::fs::write(&lock_path, b"").expect("recreate");
assert!(!lock_path_names_locked_file(&lock_path).expect("probe"));
let other = std::fs::OpenOptions::new()
.read(true)
.open(&lock_path)
.expect("open");
other.try_lock_exclusive().expect("new file is unlocked");
let _ = fs4::FileExt::unlock(&other);
let _ = fs4::FileExt::unlock(&held);
drop(other);
drop(held);
let _ = std::fs::remove_file(&lock_path);
}
#[test]
fn test_acquire_after_lock_file_replaced_locks_current_file() {
let db_path = tmp_path("replaced");
let lock_path = lock_path_for(db_path.as_path());
let first = LockFile::acquire(db_path.as_path()).expect("acquire");
std::fs::remove_file(&lock_path).expect("unlink");
drop(first);
let second = LockFile::acquire(db_path.as_path()).expect("acquire on new file");
assert!(lock_path.exists());
assert!(LockFile::acquire(db_path.as_path()).is_err());
drop(second);
let _ = std::fs::remove_file(&lock_path);
}
#[test]
fn test_read_holder_reads_a_bounded_prefix() {
let db_path = tmp_path("bounded");
let meta_path = meta_path_for(db_path.as_path());
let filler = "x".repeat(MAX_HOLDER_META_BYTES as usize);
let body = format!("{LOCKFILE_MAGIC}1\nfiller={filler}\npid=42\n");
std::fs::write(&meta_path, body).expect("write");
let result = LockFile::read_holder(db_path.as_path());
assert!(
matches!(result, Err(crate::Error::Corrupted { .. })),
"{result:?}"
);
let _ = std::fs::remove_file(&meta_path);
}
#[test]
fn test_write_holder_meta_replaces_existing_file() {
let db_path = tmp_path("replace-meta");
let meta_path = meta_path_for(db_path.as_path());
std::fs::write(&meta_path, b"stale contents that are longer than a body").expect("w");
write_holder_meta(meta_path.as_path()).expect("write");
let body = std::fs::read_to_string(&meta_path).expect("read");
assert!(body.starts_with(LOCKFILE_MAGIC));
assert!(!body.contains("stale"));
let _ = std::fs::remove_file(&meta_path);
}
#[cfg(unix)]
#[test]
fn test_write_holder_meta_does_not_follow_symlink() {
let db_path = tmp_path("meta-symlink");
let meta_path = meta_path_for(db_path.as_path());
let victim = tmp_path("meta-victim");
std::fs::write(&victim, b"victim").expect("victim");
std::os::unix::fs::symlink(&victim, &meta_path).expect("symlink");
write_holder_meta(meta_path.as_path()).expect("write");
assert_eq!(std::fs::read(&victim).expect("read"), b"victim");
assert!(!std::fs::symlink_metadata(&meta_path)
.expect("meta")
.file_type()
.is_symlink());
let _ = std::fs::remove_file(&meta_path);
let _ = std::fs::remove_file(&victim);
}
#[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());
}
}