diff --git a/src/mount/lock.rs b/src/mount/lock.rs index a78b549..fe45cff 100644 --- a/src/mount/lock.rs +++ b/src/mount/lock.rs @@ -1,31 +1,110 @@ -//! Pro-Paar-Mutex-Registry, damit ein manueller `mount --name X` nicht mit einem -//! gleichzeitig laufenden `watch` für dasselbe Paar kollidiert. +//! Pro-Paar-Dateisperre, damit ein manueller `mount --name X` nicht mit einem gleichzeitig +//! laufenden `watch` für dasselbe Paar kollidiert. +//! +//! **Warum eine Datei-basierte Sperre (`flock(2)`) statt eines simplen `Mutex`:** smart-mount +//! ist ein One-Shot-CLI (siehe [`crate::reconcile`]-Moduldoku) - jeder Aufruf ist ein eigener, +//! kurzlebiger Prozess. Ein rein prozessinterner `Mutex>` schützt daher NICHT vor +//! zwei gleichzeitig laufenden `smart-mount`-Prozessen (z. B. ein manuelles `unmount --name X` +//! während ein systemd-Timer-`watch` läuft): beide bekämen ihre eigene, leere Registry und +//! würden sich nie gegenseitig blockieren. Die Sperre muss daher außerhalb des Prozess-Speichers +//! liegen - hier über eine `flock`-Datei pro Paar (`std::fs::File::lock()`, seit Rust 1.89 +//! Teil der Standardbibliothek, keine zusätzliche Abhängigkeit nötig). //! //! Ersetzt den globalen `RwLock`+`Mutex` aus dem alten `src/filesystem/mount.rs` (v0.2.0): //! dort war die Sperre prozessweit global, hier ist sie pro Laufwerkspaar - mehrere Paare //! können also parallel gemountet werden, ohne sich gegenseitig zu blockieren. -use std::collections::HashMap; -use std::sync::{Arc, Mutex, OnceLock}; +use std::fs::{File, OpenOptions}; +use std::path::PathBuf; -use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard}; +use crate::error::{Error, Result}; -type Registry = Mutex>>>; - -fn registry() -> &'static Registry { - static REGISTRY: OnceLock = OnceLock::new(); - REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) +/// Hält die Sperrdatei offen (und damit die `flock`-Sperre über den zugehörigen +/// Datei-Deskriptor), bis der Guard gedroppt wird - Schließen des Deskriptors gibt die Sperre +/// implizit frei, ein explizites `unlock()` ist dafür nicht nötig. +pub struct PairLock { + _file: File, } -/// Sperrt ein Laufwerkspaar für die Dauer des zurückgegebenen Guards. `tokio::sync::Mutex`s -/// `lock_owned()` erlaubt einen Guard, der seine eigene `Arc`-Referenz hält - keine -/// selbstreferenzielle Struktur/`unsafe` nötig. -pub async fn acquire(pair_id: &str) -> OwnedMutexGuard<()> { - let mutex = { - let mut reg = registry().lock().unwrap_or_else(|e| e.into_inner()); - reg.entry(pair_id.to_string()) - .or_insert_with(|| Arc::new(AsyncMutex::new(()))) - .clone() - }; - mutex.lock_owned().await +/// Verzeichnis für die Sperrdateien: `/run/smart-mount/locks` im Root-/System-Kontext (root ist +/// dort ohnehin die einzige Partei, die Paare in diesem Kontext mountet), sonst +/// `$XDG_RUNTIME_DIR` (per-Nutzer, von systemd `0700`-geschützt angelegt) mit Fallback auf das +/// System-Temp-Verzeichnis, falls `$XDG_RUNTIME_DIR` nicht gesetzt ist (z. B. ohne aktive +/// Login-Session). +fn lock_dir() -> PathBuf { + if sudo_ctdra::is_run_as_root() { + PathBuf::from("/run/smart-mount/locks") + } else { + std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir) + .join("smart-mount-locks") + } +} + +fn acquire_blocking(pair_id: &str) -> Result { + let dir = lock_dir(); + std::fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?; + + let path = dir.join(format!("{pair_id}.lock")); + // `truncate(false)`: der Dateiinhalt ist irrelevant, nur der Deskriptor/die `flock`-Sperre + // darauf zählt - ein Zurücksetzen auf leer bei jedem Aufruf wäre unnötig. + let file = OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&path) + .map_err(|e| Error::io(&path, e))?; + + // Blockiert, bis die Sperre frei wird - `flock(2)` kennt keinen Async-Mechanismus, daher + // läuft dieser gesamte Aufruf über `spawn_blocking` (siehe [`acquire`]) auf einem + // Blocking-Thread statt einem Tokio-Worker-Thread. + file.lock().map_err(|e| Error::io(&path, e))?; + Ok(PairLock { _file: file }) +} + +/// Sperrt ein Laufwerkspaar prozessübergreifend für die Dauer des zurückgegebenen Guards. +pub async fn acquire(pair_id: &str) -> Result { + let pair_id = pair_id.to_string(); + tokio::task::spawn_blocking(move || acquire_blocking(&pair_id)) + .await + .map_err(|e| Error::Other(format!("lock task panicked: {e}")))? +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn acquire_returns_a_guard_and_releases_on_drop() { + let id = format!("test-lock-{}", uuid::Uuid::new_v4()); + { + let _guard = acquire(&id).await.expect("first acquire"); + } + // Sollte nicht blockieren: der obige Guard wurde bereits gedroppt. + let _guard2 = acquire(&id).await.expect("second acquire after drop"); + } + + #[tokio::test] + async fn a_second_concurrent_acquire_waits_for_the_first_to_be_dropped() { + let id = format!("test-lock-{}", uuid::Uuid::new_v4()); + let guard = acquire(&id).await.expect("first acquire"); + + let id2 = id.clone(); + let handle = tokio::spawn(async move { acquire(&id2).await }); + + // Kurz warten, damit der spawnte Task realistischerweise Zeit hatte, in `acquire` zu + // blockieren, bevor die erste Sperre freigegeben wird. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + assert!( + !handle.is_finished(), + "second acquire must block while the first guard is held" + ); + + drop(guard); + handle + .await + .expect("task") + .expect("second acquire after release"); + } } diff --git a/src/reconcile/mod.rs b/src/reconcile/mod.rs index 059dd10..e2e158c 100644 --- a/src/reconcile/mod.rs +++ b/src/reconcile/mod.rs @@ -74,7 +74,7 @@ async fn reconcile_pair_inner( settings: &GlobalSettings, creds: &CredentialStore, ) -> Result { - let _guard = lock::acquire(&pair.id).await; + let _guard = lock::acquire(&pair.id).await?; let local_reachable = check_local_reachable(&pair.local, settings); let active = target::active_side(pair); @@ -167,7 +167,7 @@ async fn mount_side( /// ein leeres, ausgehängtes Backing-Verzeichnis) - der nächste `mount`/`watch`-Lauf räumt das /// beim erneuten Aktivieren automatisch auf. pub async fn unmount_pair(pair: &DrivePair, settings: &GlobalSettings) -> Result { - let _guard = lock::acquire(&pair.id).await; + let _guard = lock::acquire(&pair.id).await?; let Some(side) = target::active_side(pair) else { return Ok(Action::NoOp);