use crate::io_timeout::STAT_TIMEOUT;
use crate::library::{bounded_op, root_cause_is_not_found, LibraryContext};
use anyhow::{bail, Context, Result};
use fs2::FileExt;
use std::fs::{File, OpenOptions};
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActivityMode {
Shared,
Exclusive,
}
#[derive(Debug)]
pub struct ActivityGuard(#[allow(dead_code)] File);
#[derive(Debug)]
pub struct InitGuard(#[allow(dead_code)] File);
#[derive(Debug)]
pub struct CommandGuard {
#[allow(dead_code)]
file: File,
root: PathBuf,
command: String,
}
impl CommandGuard {
pub(crate) fn ensure_matches(&self, ctx: &LibraryContext, command: &str) -> Result<()> {
anyhow::ensure!(
self.command == command,
"the command lock is held for '{}', not '{}'",
self.command,
command
);
anyhow::ensure!(
self.root == ctx.paths.root,
"the command lock was taken for library {}, not {}",
self.root.display(),
ctx.paths.root.display()
);
Ok(())
}
}
fn lock_path(ctx: &LibraryContext, kind: &str) -> PathBuf {
ctx.paths.locks.join(format!("{kind}.lock"))
}
fn validate_command_name(command: &str) -> Result<()> {
anyhow::ensure!(!command.is_empty(), "a command lock needs a command name");
anyhow::ensure!(
!command.contains('/') && !command.contains('\\') && !command.starts_with('.'),
"{command:?} is not a valid command lock name"
);
Ok(())
}
fn lstat_maybe(path: &Path) -> Result<Option<std::fs::Metadata>> {
let owned = path.to_path_buf();
match bounded_op(path, "read", STAT_TIMEOUT, move || {
std::fs::symlink_metadata(&owned)
}) {
Ok(meta) => Ok(Some(meta)),
Err(e) if root_cause_is_not_found(&e) => Ok(None),
Err(e) => Err(e),
}
}
pub(crate) fn reject_redirect(path: &Path, what: &str) -> Result<()> {
let Some(meta) = lstat_maybe(path)? else {
return Ok(());
};
if meta.file_type().is_symlink() {
bail!(
"{what} {} must not be a symlink; redirected library state is not supported",
path.display()
);
}
if meta.nlink() > 1 {
bail!(
"{what} {} is hard-linked into another location; two libraries cannot share one {}",
path.display(),
what
);
}
Ok(())
}
pub(crate) fn reject_dir_redirect(path: &Path, what: &str) -> Result<()> {
let Some(meta) = lstat_maybe(path)? else {
return Ok(());
};
if meta.file_type().is_symlink() {
bail!(
"{what} {} must not be a symlink; redirected library state is not supported",
path.display()
);
}
Ok(())
}
fn existing_dir(path: &Path) -> Result<Option<std::fs::Metadata>> {
let Some(meta) = lstat_maybe(path)? else {
return Ok(None);
};
if meta.file_type().is_symlink() {
bail!(
"{} must not be a symlink; redirected library state is not supported",
path.display()
);
}
anyhow::ensure!(meta.is_dir(), "{} is not a directory", path.display());
Ok(Some(meta))
}
pub(crate) fn verify_state(ctx: &LibraryContext) -> Result<()> {
ctx.ensure_root_identity()?;
if existing_dir(&ctx.paths.state)?.is_none() {
bail!(
"library {} is not initialized: {} does not exist",
ctx.paths.root.display(),
ctx.paths.state.display()
);
}
Ok(())
}
pub(crate) fn ensure_state_and_locks(ctx: &LibraryContext) -> Result<()> {
ctx.ensure_root_identity()?;
existing_dir(&ctx.paths.state)?;
existing_dir(&ctx.paths.locks)?;
let owned = ctx.paths.locks.clone();
bounded_op(&ctx.paths.locks, "create", STAT_TIMEOUT, move || {
std::fs::create_dir_all(&owned)
})
.with_context(|| format!("create {}", ctx.paths.locks.display()))?;
Ok(())
}
fn require_locks(ctx: &LibraryContext) -> Result<()> {
verify_state(ctx)?;
if existing_dir(&ctx.paths.locks)?.is_none() {
bail!(
"library {} is not initialized: {} does not exist",
ctx.paths.root.display(),
ctx.paths.locks.display()
);
}
Ok(())
}
fn acquire_lock_file(path: &Path, exclusive: bool, busy: String, what: &str) -> Result<File> {
reject_redirect(path, what)?;
let owned = path.to_path_buf();
let file = bounded_op(path, "open", STAT_TIMEOUT, move || {
OpenOptions::new()
.read(true)
.write(true)
.create(true)
.open(&owned)
})
.with_context(|| format!("open lock file {}", path.display()))?;
let taken = if exclusive {
FileExt::try_lock_exclusive(&file)
} else {
FileExt::try_lock_shared(&file)
};
taken.map_err(|_| anyhow::anyhow!("{busy}"))?;
Ok(file)
}
pub fn try_activity(ctx: &LibraryContext, mode: ActivityMode) -> Result<ActivityGuard> {
require_locks(ctx)?;
let busy = format!(
"library {} is in use by another videre process (its activity lock is held)",
ctx.paths.root.display()
);
let file = acquire_lock_file(
&lock_path(ctx, "activity"),
mode == ActivityMode::Exclusive,
busy,
"the library activity lock file",
)?;
Ok(ActivityGuard(file))
}
pub fn try_init(ctx: &LibraryContext) -> Result<InitGuard> {
require_locks(ctx)?;
let busy = format!(
"another videre process is initializing library {}",
ctx.paths.root.display()
);
let file = acquire_lock_file(
&lock_path(ctx, "init"),
true,
busy,
"the library init lock file",
)?;
Ok(InitGuard(file))
}
pub fn try_command(ctx: &LibraryContext, command: &str) -> Result<CommandGuard> {
validate_command_name(command)?;
require_locks(ctx)?;
let busy = format!(
"{command} is already running against library {}",
ctx.paths.root.display()
);
let file = acquire_lock_file(
&lock_path(ctx, command),
true,
busy,
"the library command lock file",
)?;
Ok(CommandGuard {
file,
root: ctx.paths.root.clone(),
command: command.to_string(),
})
}
pub fn command_locked(ctx: &LibraryContext, command: &str) -> Result<bool> {
validate_command_name(command)?;
let path = lock_path(ctx, command);
reject_redirect(&path, "the library command lock file")?;
let Some(meta) = lstat_maybe(&path)? else {
return Ok(false);
};
anyhow::ensure!(
!meta.is_dir(),
"lock file {} is a directory",
path.display()
);
let owned = path.clone();
let file = bounded_op(&path, "open", STAT_TIMEOUT, move || {
OpenOptions::new().read(true).write(true).open(&owned)
})
.with_context(|| format!("open lock file {}", path.display()))?;
match file.try_lock_exclusive() {
Ok(()) => {
FileExt::unlock(&file).ok();
Ok(false)
}
Err(_) => Ok(true),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::library::LibraryContext;
fn locked_library() -> (tempfile::TempDir, LibraryContext) {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("photos");
std::fs::create_dir(&root).unwrap();
let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
std::fs::create_dir_all(&ctx.paths.locks).unwrap();
(temp, ctx)
}
#[test]
fn shared_activity_coexists_but_exclusive_does_not() {
let (_t, ctx) = locked_library();
let one = try_activity(&ctx, ActivityMode::Shared).unwrap();
let two = try_activity(&ctx, ActivityMode::Shared).unwrap();
assert!(
try_activity(&ctx, ActivityMode::Exclusive).is_err(),
"a shared activity lock must refuse an exclusive taker"
);
drop(one);
assert!(
try_activity(&ctx, ActivityMode::Exclusive).is_err(),
"one remaining shared holder is still enough to refuse exclusive"
);
drop(two);
let _ex = try_activity(&ctx, ActivityMode::Exclusive).unwrap();
}
#[test]
fn an_exclusive_activity_lock_refuses_both_modes() {
let (_t, ctx) = locked_library();
let _ex = try_activity(&ctx, ActivityMode::Exclusive).unwrap();
assert!(try_activity(&ctx, ActivityMode::Exclusive).is_err());
assert!(try_activity(&ctx, ActivityMode::Shared).is_err());
}
#[test]
fn an_held_init_lock_refuses_a_second_taker_and_an_edit() {
let (_t, ctx) = locked_library();
let held = try_init(&ctx).unwrap();
let err = try_init(&ctx).unwrap_err();
assert!(format!("{err:#}").contains("initializing"), "{err:#}");
let err = crate::library_config::edit(
&ctx,
crate::library_config::ConfigKey::ReadRate,
Some(toml::Value::Integer(9)),
)
.unwrap_err();
assert!(
format!("{err:#}").contains("initializing"),
"an edit must fail while the init lock is held: {err:#}"
);
drop(held);
crate::library_config::edit(
&ctx,
crate::library_config::ConfigKey::ReadRate,
Some(toml::Value::Integer(9)),
)
.unwrap();
}
#[test]
fn command_locks_contend_only_with_themselves() {
let (_t, ctx) = locked_library();
let scan = try_command(&ctx, "scan").unwrap();
assert!(try_command(&ctx, "scan").is_err());
let _faces = try_command(&ctx, "faces").unwrap();
assert!(command_locked(&ctx, "scan").unwrap());
assert!(command_locked(&ctx, "faces").unwrap());
assert!(!command_locked(&ctx, "watch").unwrap());
drop(scan);
assert!(!command_locked(&ctx, "scan").unwrap());
try_command(&ctx, "scan").unwrap();
}
#[test]
fn a_missing_state_directory_fails_without_creating_anything() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("photos");
std::fs::create_dir(&root).unwrap();
let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
assert!(try_activity(&ctx, ActivityMode::Shared).is_err());
assert!(try_init(&ctx).is_err());
assert!(try_command(&ctx, "scan").is_err());
assert!(!ctx.paths.state.exists());
assert!(!command_locked(&ctx, "scan").unwrap());
assert!(!ctx.paths.state.exists());
}
#[test]
fn lock_files_are_never_unlinked() {
let (_t, ctx) = locked_library();
{
let _a = try_activity(&ctx, ActivityMode::Shared).unwrap();
let _i = try_init(&ctx).unwrap();
let _c = try_command(&ctx, "scan").unwrap();
}
assert!(lock_path(&ctx, "activity").exists());
assert!(lock_path(&ctx, "init").exists());
assert!(lock_path(&ctx, "scan").exists());
}
#[test]
fn redirected_lock_files_are_refused() {
let (_t, ctx) = locked_library();
let outside = ctx.paths.root.join("outside.lock");
std::os::unix::fs::symlink(&outside, lock_path(&ctx, "activity")).unwrap();
let err = try_activity(&ctx, ActivityMode::Shared).unwrap_err();
assert!(format!("{err:#}").contains("symlink"), "{err:#}");
std::fs::remove_file(lock_path(&ctx, "activity")).unwrap();
let real = ctx.paths.locks.join("real.lock");
std::fs::write(&real, b"").unwrap();
std::fs::hard_link(&real, lock_path(&ctx, "init")).unwrap();
let err = try_init(&ctx).unwrap_err();
assert!(format!("{err:#}").contains("hard-linked"), "{err:#}");
}
#[test]
fn a_symlinked_state_directory_is_refused_for_locking() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("photos");
let elsewhere = temp.path().join("elsewhere");
std::fs::create_dir(&root).unwrap();
std::fs::create_dir(&elsewhere).unwrap();
std::fs::create_dir(elsewhere.join("locks")).unwrap();
let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
std::os::unix::fs::symlink(&elsewhere, root.join(".videre")).unwrap();
let err = try_activity(&ctx, ActivityMode::Shared).unwrap_err();
assert!(format!("{err:#}").contains("symlink"), "{err:#}");
}
#[test]
fn root_aliases_share_one_librarys_locks() {
let (temp, ctx) = locked_library();
let alias = temp.path().join("alias");
std::os::unix::fs::symlink(&ctx.paths.root, &alias).unwrap();
let via_alias = LibraryContext::new(&alias, &temp.path().join("cache")).unwrap();
assert_eq!(via_alias.paths.locks, ctx.paths.locks);
let _held = try_init(&via_alias).unwrap();
assert!(
try_init(&ctx).is_err(),
"a lock taken through the alias must be visible through the root"
);
assert!(try_activity(&ctx, ActivityMode::Exclusive).is_ok());
}
#[test]
fn command_names_that_cannot_be_one_file_are_refused() {
let (_t, ctx) = locked_library();
for bad in ["", "a/b", "..", ".hidden", "a\\b"] {
assert!(try_command(&ctx, bad).is_err(), "{bad:?}");
assert!(command_locked(&ctx, bad).is_err(), "{bad:?}");
}
}
#[test]
fn a_command_guard_reports_the_library_and_command_it_was_taken_for() {
let (temp, ctx) = locked_library();
let guard = try_command(&ctx, "scan").unwrap();
assert!(guard.ensure_matches(&ctx, "scan").is_ok());
assert!(guard.ensure_matches(&ctx, "faces").is_err());
let other_root = temp.path().join("other");
std::fs::create_dir(&other_root).unwrap();
let other = LibraryContext::new(&other_root, &temp.path().join("cache")).unwrap();
assert!(guard.ensure_matches(&other, "scan").is_err());
}
#[test]
fn a_lock_file_open_past_its_budget_fails_closed_without_restatting_it() {
let (_t, ctx) = locked_library();
let lock = lock_path(&ctx, "activity");
std::fs::write(&lock, b"").unwrap();
let start = std::time::Instant::now();
let owned = lock.clone();
let err = crate::library::bounded_op(
&lock,
"open",
std::time::Duration::from_millis(50),
move || {
std::thread::sleep(std::time::Duration::from_secs(5));
OpenOptions::new()
.read(true)
.write(true)
.create(true)
.open(&owned)
.map(|_| ())
},
)
.unwrap_err();
std::fs::remove_file(&lock).unwrap();
let msg = format!("{err:#}");
assert!(msg.contains("did not respond"), "{msg}");
assert!(msg.contains("activity.lock"), "{msg}");
assert!(start.elapsed() < std::time::Duration::from_secs(2));
}
}