1use crate::io_timeout::STAT_TIMEOUT;
41use crate::library::{bounded_op, root_cause_is_not_found, LibraryContext};
42use anyhow::{bail, Context, Result};
43use fs2::FileExt;
44use std::fs::{File, OpenOptions};
45use std::os::unix::fs::MetadataExt;
46use std::path::{Path, PathBuf};
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum ActivityMode {
52 Shared,
54 Exclusive,
56}
57
58#[derive(Debug)]
63pub struct ActivityGuard(#[allow(dead_code)] File);
64
65#[derive(Debug)]
68pub struct InitGuard(#[allow(dead_code)] File);
69
70#[derive(Debug)]
75pub struct CommandGuard {
76 #[allow(dead_code)]
77 file: File,
78 root: PathBuf,
79 command: String,
80}
81
82impl CommandGuard {
83 pub(crate) fn ensure_matches(&self, ctx: &LibraryContext, command: &str) -> Result<()> {
93 anyhow::ensure!(
94 self.command == command,
95 "the command lock is held for '{}', not '{}'",
96 self.command,
97 command
98 );
99 anyhow::ensure!(
100 self.root == ctx.paths.root,
101 "the command lock was taken for library {}, not {}",
102 self.root.display(),
103 ctx.paths.root.display()
104 );
105 Ok(())
106 }
107}
108
109fn lock_path(ctx: &LibraryContext, kind: &str) -> PathBuf {
111 ctx.paths.locks.join(format!("{kind}.lock"))
112}
113
114fn validate_command_name(command: &str) -> Result<()> {
120 anyhow::ensure!(!command.is_empty(), "a command lock needs a command name");
121 anyhow::ensure!(
122 !command.contains('/') && !command.contains('\\') && !command.starts_with('.'),
123 "{command:?} is not a valid command lock name"
124 );
125 Ok(())
126}
127
128fn lstat_maybe(path: &Path) -> Result<Option<std::fs::Metadata>> {
132 let owned = path.to_path_buf();
133 match bounded_op(path, "read", STAT_TIMEOUT, move || {
134 std::fs::symlink_metadata(&owned)
135 }) {
136 Ok(meta) => Ok(Some(meta)),
137 Err(e) if root_cause_is_not_found(&e) => Ok(None),
138 Err(e) => Err(e),
139 }
140}
141
142pub(crate) fn reject_redirect(path: &Path, what: &str) -> Result<()> {
152 let Some(meta) = lstat_maybe(path)? else {
153 return Ok(());
154 };
155 if meta.file_type().is_symlink() {
156 bail!(
157 "{what} {} must not be a symlink; redirected library state is not supported",
158 path.display()
159 );
160 }
161 if meta.nlink() > 1 {
162 bail!(
163 "{what} {} is hard-linked into another location; two libraries cannot share one {}",
164 path.display(),
165 what
166 );
167 }
168 Ok(())
169}
170
171pub(crate) fn reject_dir_redirect(path: &Path, what: &str) -> Result<()> {
178 let Some(meta) = lstat_maybe(path)? else {
179 return Ok(());
180 };
181 if meta.file_type().is_symlink() {
182 bail!(
183 "{what} {} must not be a symlink; redirected library state is not supported",
184 path.display()
185 );
186 }
187 Ok(())
188}
189
190fn existing_dir(path: &Path) -> Result<Option<std::fs::Metadata>> {
195 let Some(meta) = lstat_maybe(path)? else {
196 return Ok(None);
197 };
198 if meta.file_type().is_symlink() {
199 bail!(
200 "{} must not be a symlink; redirected library state is not supported",
201 path.display()
202 );
203 }
204 anyhow::ensure!(meta.is_dir(), "{} is not a directory", path.display());
205 Ok(Some(meta))
206}
207
208pub(crate) fn verify_state(ctx: &LibraryContext) -> Result<()> {
212 ctx.ensure_root_identity()?;
213 if existing_dir(&ctx.paths.state)?.is_none() {
214 bail!(
215 "library {} is not initialized: {} does not exist",
216 ctx.paths.root.display(),
217 ctx.paths.state.display()
218 );
219 }
220 Ok(())
221}
222
223pub(crate) fn ensure_state_and_locks(ctx: &LibraryContext) -> Result<()> {
228 ctx.ensure_root_identity()?;
229 existing_dir(&ctx.paths.state)?;
230 existing_dir(&ctx.paths.locks)?;
231 let owned = ctx.paths.locks.clone();
232 bounded_op(&ctx.paths.locks, "create", STAT_TIMEOUT, move || {
233 std::fs::create_dir_all(&owned)
234 })
235 .with_context(|| format!("create {}", ctx.paths.locks.display()))?;
236 Ok(())
237}
238
239fn require_locks(ctx: &LibraryContext) -> Result<()> {
244 verify_state(ctx)?;
245 if existing_dir(&ctx.paths.locks)?.is_none() {
246 bail!(
247 "library {} is not initialized: {} does not exist",
248 ctx.paths.root.display(),
249 ctx.paths.locks.display()
250 );
251 }
252 Ok(())
253}
254
255fn acquire_lock_file(path: &Path, exclusive: bool, busy: String, what: &str) -> Result<File> {
258 reject_redirect(path, what)?;
259 let owned = path.to_path_buf();
260 let file = bounded_op(path, "open", STAT_TIMEOUT, move || {
261 OpenOptions::new()
262 .read(true)
263 .write(true)
264 .create(true)
265 .open(&owned)
266 })
267 .with_context(|| format!("open lock file {}", path.display()))?;
268 let taken = if exclusive {
273 FileExt::try_lock_exclusive(&file)
274 } else {
275 FileExt::try_lock_shared(&file)
276 };
277 taken.map_err(|_| anyhow::anyhow!("{busy}"))?;
278 Ok(file)
279}
280
281pub fn try_activity(ctx: &LibraryContext, mode: ActivityMode) -> Result<ActivityGuard> {
285 require_locks(ctx)?;
286 let busy = format!(
287 "library {} is in use by another videre process (its activity lock is held)",
288 ctx.paths.root.display()
289 );
290 let file = acquire_lock_file(
291 &lock_path(ctx, "activity"),
292 mode == ActivityMode::Exclusive,
293 busy,
294 "the library activity lock file",
295 )?;
296 Ok(ActivityGuard(file))
297}
298
299pub fn try_init(ctx: &LibraryContext) -> Result<InitGuard> {
303 require_locks(ctx)?;
304 let busy = format!(
305 "another videre process is initializing library {}",
306 ctx.paths.root.display()
307 );
308 let file = acquire_lock_file(
309 &lock_path(ctx, "init"),
310 true,
311 busy,
312 "the library init lock file",
313 )?;
314 Ok(InitGuard(file))
315}
316
317pub fn try_command(ctx: &LibraryContext, command: &str) -> Result<CommandGuard> {
321 validate_command_name(command)?;
322 require_locks(ctx)?;
323 let busy = format!(
324 "{command} is already running against library {}",
325 ctx.paths.root.display()
326 );
327 let file = acquire_lock_file(
328 &lock_path(ctx, command),
329 true,
330 busy,
331 "the library command lock file",
332 )?;
333 Ok(CommandGuard {
334 file,
335 root: ctx.paths.root.clone(),
336 command: command.to_string(),
337 })
338}
339
340pub fn command_locked(ctx: &LibraryContext, command: &str) -> Result<bool> {
345 validate_command_name(command)?;
346 let path = lock_path(ctx, command);
347 reject_redirect(&path, "the library command lock file")?;
351 let Some(meta) = lstat_maybe(&path)? else {
352 return Ok(false);
353 };
354 anyhow::ensure!(
355 !meta.is_dir(),
356 "lock file {} is a directory",
357 path.display()
358 );
359 let owned = path.clone();
360 let file = bounded_op(&path, "open", STAT_TIMEOUT, move || {
361 OpenOptions::new().read(true).write(true).open(&owned)
362 })
363 .with_context(|| format!("open lock file {}", path.display()))?;
364 match file.try_lock_exclusive() {
365 Ok(()) => {
368 FileExt::unlock(&file).ok();
369 Ok(false)
370 }
371 Err(_) => Ok(true),
372 }
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378 use crate::library::LibraryContext;
379
380 fn locked_library() -> (tempfile::TempDir, LibraryContext) {
384 let temp = tempfile::tempdir().unwrap();
385 let root = temp.path().join("photos");
386 std::fs::create_dir(&root).unwrap();
387 let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
388 std::fs::create_dir_all(&ctx.paths.locks).unwrap();
389 (temp, ctx)
390 }
391
392 #[test]
393 fn shared_activity_coexists_but_exclusive_does_not() {
394 let (_t, ctx) = locked_library();
395 let one = try_activity(&ctx, ActivityMode::Shared).unwrap();
396 let two = try_activity(&ctx, ActivityMode::Shared).unwrap();
397 assert!(
399 try_activity(&ctx, ActivityMode::Exclusive).is_err(),
400 "a shared activity lock must refuse an exclusive taker"
401 );
402 drop(one);
403 assert!(
404 try_activity(&ctx, ActivityMode::Exclusive).is_err(),
405 "one remaining shared holder is still enough to refuse exclusive"
406 );
407 drop(two);
408 let _ex = try_activity(&ctx, ActivityMode::Exclusive).unwrap();
410 }
411
412 #[test]
413 fn an_exclusive_activity_lock_refuses_both_modes() {
414 let (_t, ctx) = locked_library();
415 let _ex = try_activity(&ctx, ActivityMode::Exclusive).unwrap();
416 assert!(try_activity(&ctx, ActivityMode::Exclusive).is_err());
417 assert!(try_activity(&ctx, ActivityMode::Shared).is_err());
418 }
419
420 #[test]
421 fn an_held_init_lock_refuses_a_second_taker_and_an_edit() {
422 let (_t, ctx) = locked_library();
423 let held = try_init(&ctx).unwrap();
424 let err = try_init(&ctx).unwrap_err();
425 assert!(format!("{err:#}").contains("initializing"), "{err:#}");
426 let err = crate::library_config::edit(
429 &ctx,
430 crate::library_config::ConfigKey::ReadRate,
431 Some(toml::Value::Integer(9)),
432 )
433 .unwrap_err();
434 assert!(
435 format!("{err:#}").contains("initializing"),
436 "an edit must fail while the init lock is held: {err:#}"
437 );
438 drop(held);
439 crate::library_config::edit(
440 &ctx,
441 crate::library_config::ConfigKey::ReadRate,
442 Some(toml::Value::Integer(9)),
443 )
444 .unwrap();
445 }
446
447 #[test]
448 fn command_locks_contend_only_with_themselves() {
449 let (_t, ctx) = locked_library();
450 let scan = try_command(&ctx, "scan").unwrap();
451 assert!(try_command(&ctx, "scan").is_err());
452 let _faces = try_command(&ctx, "faces").unwrap();
453 assert!(command_locked(&ctx, "scan").unwrap());
456 assert!(command_locked(&ctx, "faces").unwrap());
457 assert!(!command_locked(&ctx, "watch").unwrap());
458 drop(scan);
459 assert!(!command_locked(&ctx, "scan").unwrap());
460 try_command(&ctx, "scan").unwrap();
461 }
462
463 #[test]
464 fn a_missing_state_directory_fails_without_creating_anything() {
465 let temp = tempfile::tempdir().unwrap();
466 let root = temp.path().join("photos");
467 std::fs::create_dir(&root).unwrap();
468 let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
469 assert!(try_activity(&ctx, ActivityMode::Shared).is_err());
470 assert!(try_init(&ctx).is_err());
471 assert!(try_command(&ctx, "scan").is_err());
472 assert!(!ctx.paths.state.exists());
475 assert!(!command_locked(&ctx, "scan").unwrap());
478 assert!(!ctx.paths.state.exists());
479 }
480
481 #[test]
482 fn lock_files_are_never_unlinked() {
483 let (_t, ctx) = locked_library();
484 {
485 let _a = try_activity(&ctx, ActivityMode::Shared).unwrap();
486 let _i = try_init(&ctx).unwrap();
487 let _c = try_command(&ctx, "scan").unwrap();
488 }
489 assert!(lock_path(&ctx, "activity").exists());
492 assert!(lock_path(&ctx, "init").exists());
493 assert!(lock_path(&ctx, "scan").exists());
494 }
495
496 #[test]
497 fn redirected_lock_files_are_refused() {
498 let (_t, ctx) = locked_library();
499 let outside = ctx.paths.root.join("outside.lock");
502 std::os::unix::fs::symlink(&outside, lock_path(&ctx, "activity")).unwrap();
503 let err = try_activity(&ctx, ActivityMode::Shared).unwrap_err();
504 assert!(format!("{err:#}").contains("symlink"), "{err:#}");
505 std::fs::remove_file(lock_path(&ctx, "activity")).unwrap();
506
507 let real = ctx.paths.locks.join("real.lock");
509 std::fs::write(&real, b"").unwrap();
510 std::fs::hard_link(&real, lock_path(&ctx, "init")).unwrap();
511 let err = try_init(&ctx).unwrap_err();
512 assert!(format!("{err:#}").contains("hard-linked"), "{err:#}");
513 }
514
515 #[test]
516 fn a_symlinked_state_directory_is_refused_for_locking() {
517 let temp = tempfile::tempdir().unwrap();
518 let root = temp.path().join("photos");
519 let elsewhere = temp.path().join("elsewhere");
520 std::fs::create_dir(&root).unwrap();
521 std::fs::create_dir(&elsewhere).unwrap();
522 std::fs::create_dir(elsewhere.join("locks")).unwrap();
523 let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
524 std::os::unix::fs::symlink(&elsewhere, root.join(".videre")).unwrap();
528 let err = try_activity(&ctx, ActivityMode::Shared).unwrap_err();
529 assert!(format!("{err:#}").contains("symlink"), "{err:#}");
530 }
531
532 #[test]
533 fn root_aliases_share_one_librarys_locks() {
534 let (temp, ctx) = locked_library();
535 let alias = temp.path().join("alias");
536 std::os::unix::fs::symlink(&ctx.paths.root, &alias).unwrap();
537 let via_alias = LibraryContext::new(&alias, &temp.path().join("cache")).unwrap();
540 assert_eq!(via_alias.paths.locks, ctx.paths.locks);
541 let _held = try_init(&via_alias).unwrap();
542 assert!(
543 try_init(&ctx).is_err(),
544 "a lock taken through the alias must be visible through the root"
545 );
546 assert!(try_activity(&ctx, ActivityMode::Exclusive).is_ok());
547 }
548
549 #[test]
550 fn command_names_that_cannot_be_one_file_are_refused() {
551 let (_t, ctx) = locked_library();
552 for bad in ["", "a/b", "..", ".hidden", "a\\b"] {
553 assert!(try_command(&ctx, bad).is_err(), "{bad:?}");
554 assert!(command_locked(&ctx, bad).is_err(), "{bad:?}");
555 }
556 }
557
558 #[test]
559 fn a_command_guard_reports_the_library_and_command_it_was_taken_for() {
560 let (temp, ctx) = locked_library();
561 let guard = try_command(&ctx, "scan").unwrap();
562 assert!(guard.ensure_matches(&ctx, "scan").is_ok());
563 assert!(guard.ensure_matches(&ctx, "faces").is_err());
564 let other_root = temp.path().join("other");
567 std::fs::create_dir(&other_root).unwrap();
568 let other = LibraryContext::new(&other_root, &temp.path().join("cache")).unwrap();
569 assert!(guard.ensure_matches(&other, "scan").is_err());
570 }
571
572 #[test]
573 fn a_lock_file_open_past_its_budget_fails_closed_without_restatting_it() {
574 let (_t, ctx) = locked_library();
584 let lock = lock_path(&ctx, "activity");
585 std::fs::write(&lock, b"").unwrap();
586 let start = std::time::Instant::now();
587 let owned = lock.clone();
588 let err = crate::library::bounded_op(
589 &lock,
590 "open",
591 std::time::Duration::from_millis(50),
592 move || {
593 std::thread::sleep(std::time::Duration::from_secs(5));
594 OpenOptions::new()
595 .read(true)
596 .write(true)
597 .create(true)
598 .open(&owned)
599 .map(|_| ())
600 },
601 )
602 .unwrap_err();
603 std::fs::remove_file(&lock).unwrap();
610 let msg = format!("{err:#}");
611 assert!(msg.contains("did not respond"), "{msg}");
612 assert!(msg.contains("activity.lock"), "{msg}");
613 assert!(start.elapsed() < std::time::Duration::from_secs(2));
614 }
615}