Fix: Paar-Sperre ist jetzt prozessuebergreifend statt nur prozessintern

mount::lock::acquire() war ein rein prozessinterner Mutex<HashMap<...>>.
smart-mount ist aber ein One-Shot-CLI (siehe reconcile-Moduldoku) - jeder
Aufruf ist ein eigener Prozess mit eigener, leerer Registry. Ein manuelles
`unmount --name X` waehrend ein gleichzeitig laufender systemd-Timer-
`watch` fuer dasselbe Paar arbeitet, kollidierte dadurch ungebremst statt
serialisiert zu werden.

acquire() sperrt jetzt ueber eine `flock(2)`-Datei pro Paar
(`std::fs::File::lock()`, seit Rust 1.89 Teil der Standardbibliothek) in
einem root- bzw. XDG_RUNTIME_DIR-basierten Verzeichnis - damit blockieren
sich zwei Prozesse fuer dasselbe Paar tatsaechlich gegenseitig.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 21:41:49 +02:00
co-authored by Claude Sonnet 5
parent c757d19eae
commit af88a306f9
2 changed files with 102 additions and 23 deletions
+100 -21
View File
@@ -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<HashMap<...>>` 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<HashMap<String, Arc<AsyncMutex<()>>>>;
fn registry() -> &'static Registry {
static REGISTRY: OnceLock<Registry> = 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<PairLock> {
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<PairLock> {
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");
}
}
+2 -2
View File
@@ -74,7 +74,7 @@ async fn reconcile_pair_inner(
settings: &GlobalSettings,
creds: &CredentialStore,
) -> Result<Action> {
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<Action> {
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);