Files
lanspread/crates/lanspread-peer/src/services/local_monitor.rs
T
ddidderr 6c8a2bb9f0 feat(peer): add transactional local game operations
Implement the peer-owned state model from PLAN.md. A root-level version.ini
is now the download completion sentinel, local/ as a directory is the install
predicate, and exact root-level version.ini detection prevents nested files
from becoming sentinels by accident.

Add the peer operation table that gates downloads, installs, updates, and
uninstalls by game ID. Serving paths now reject non-catalog games, active
operations, missing sentinels, and any request that points under local/.
Remote aggregation treats LocalOnly peers as non-downloadable so they do not
contribute peer counts, candidate source selection, or latest-version checks.

Move install-side filesystem mutation into lanspread-peer::install. The new
module writes atomic .lanspread.json intents, uses .local.installing and
.local.backup with .lanspread_owned markers, and performs startup recovery
from recorded intent plus filesystem state. Downloads now buffer version.ini
chunks in memory and commit the sentinel last through .version.ini.tmp.

Replace the fixed 15-second monitor with notify-backed non-recursive watches,
per-ID rescan gating, and a 300-second fallback scan. The optimized rescan
path updates one cached library-index entry and active operation IDs preserve
their previous summary during scans.

Test Plan:
- just fmt
- just clippy
- just test
- just build

Refs: PLAN.md
2026-05-15 18:18:55 +02:00

373 lines
10 KiB
Rust

//! Local game directory monitor.
use std::{
collections::HashSet,
path::{Component, Path, PathBuf},
sync::Arc,
time::Duration,
};
use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher};
use tokio::sync::{RwLock, mpsc::UnboundedSender};
use crate::{
PeerEvent,
config::LOCAL_GAME_FALLBACK_SCAN_SECS,
context::Ctx,
handlers::update_and_announce_games,
local_games::{
is_ignored_game_root_name,
is_local_dir_name,
rescan_local_game,
scan_local_library,
},
};
struct WatchState {
watcher: RecommendedWatcher,
game_dir: PathBuf,
watched: HashSet<PathBuf>,
}
#[derive(Clone, Default)]
struct RescanGate {
running: Arc<RwLock<HashSet<String>>>,
pending: Arc<RwLock<HashSet<String>>>,
}
/// Monitors the local game directory for changes.
pub async fn run_local_game_monitor(
tx_notify_ui: UnboundedSender<PeerEvent>,
ctx: Ctx,
) -> eyre::Result<()> {
log::info!("Starting notify-based local game directory monitor");
let (watch_tx, mut watch_rx) = tokio::sync::mpsc::unbounded_channel::<notify::Result<Event>>();
let mut watch_state = build_watch_state(&ctx, watch_tx.clone()).await;
let gate = RescanGate::default();
let mut fallback_interval =
tokio::time::interval(Duration::from_secs(LOCAL_GAME_FALLBACK_SCAN_SECS));
loop {
tokio::select! {
() = ctx.shutdown.cancelled() => return Ok(()),
_ = fallback_interval.tick() => {
run_fallback_scan(&ctx, &tx_notify_ui).await;
reconcile_watch_state(&ctx, &mut watch_state, watch_tx.clone()).await;
}
Some(event) = watch_rx.recv() => {
handle_watch_event(
&ctx,
&tx_notify_ui,
&gate,
event,
).await;
reconcile_watch_state(&ctx, &mut watch_state, watch_tx.clone()).await;
}
}
}
}
async fn build_watch_state(
ctx: &Ctx,
watch_tx: tokio::sync::mpsc::UnboundedSender<notify::Result<Event>>,
) -> Option<WatchState> {
let game_dir = ctx.game_dir.read().await.clone();
let mut fs_watcher = match RecommendedWatcher::new(
move |result| {
let _ = watch_tx.send(result);
},
Config::default(),
) {
Ok(watcher) => watcher,
Err(err) => {
log::warn!("Filesystem watcher unavailable; falling back to periodic scans: {err}");
return None;
}
};
let watched_paths = match watch_game_roots(&mut fs_watcher, &game_dir).await {
Ok(paths) => paths,
Err(err) => {
log::warn!(
"Failed to initialize filesystem watcher for {}: {err}; falling back to periodic scans",
game_dir.display()
);
return None;
}
};
Some(WatchState {
watcher: fs_watcher,
game_dir,
watched: watched_paths,
})
}
async fn reconcile_watch_state(
ctx: &Ctx,
watch_state: &mut Option<WatchState>,
watch_tx: tokio::sync::mpsc::UnboundedSender<notify::Result<Event>>,
) {
let current_game_dir = ctx.game_dir.read().await.clone();
if watch_state
.as_ref()
.is_none_or(|state| state.game_dir != current_game_dir)
{
*watch_state = build_watch_state(ctx, watch_tx).await;
return;
}
if let Some(state) = watch_state
&& let Err(err) = reconcile_game_root_watches(state).await
{
log::warn!(
"Failed to reconcile filesystem watches for {}: {err}",
state.game_dir.display()
);
}
}
async fn watch_game_roots(
watcher: &mut RecommendedWatcher,
game_dir: &Path,
) -> eyre::Result<HashSet<PathBuf>> {
let mut watched_paths = HashSet::new();
watch_path(watcher, game_dir, &mut watched_paths)?;
for root in list_game_roots(game_dir).await? {
watch_path(watcher, &root, &mut watched_paths)?;
}
Ok(watched_paths)
}
async fn reconcile_game_root_watches(state: &mut WatchState) -> eyre::Result<()> {
let desired = {
let mut desired = HashSet::from([state.game_dir.clone()]);
desired.extend(list_game_roots(&state.game_dir).await?);
desired
};
let stale_paths = state
.watched
.difference(&desired)
.cloned()
.collect::<Vec<_>>();
for path in stale_paths {
if let Err(err) = state.watcher.unwatch(&path) {
log::debug!("Failed to unwatch {}: {err}", path.display());
}
state.watched.remove(&path);
}
let new_paths = desired
.difference(&state.watched)
.cloned()
.collect::<Vec<_>>();
for path in new_paths {
watch_path(&mut state.watcher, &path, &mut state.watched)?;
}
Ok(())
}
fn watch_path(
watcher: &mut RecommendedWatcher,
path: &Path,
watched_paths: &mut HashSet<PathBuf>,
) -> notify::Result<()> {
watcher.watch(path, RecursiveMode::NonRecursive)?;
watched_paths.insert(path.to_path_buf());
Ok(())
}
async fn list_game_roots(game_dir: &Path) -> eyre::Result<Vec<PathBuf>> {
let mut roots = Vec::new();
let mut entries = match tokio::fs::read_dir(game_dir).await {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(roots),
Err(err) => return Err(err.into()),
};
while let Some(entry) = entries.next_entry().await? {
if !entry.file_type().await?.is_dir() {
continue;
}
let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else {
continue;
};
if is_ignored_game_root_name(&name) {
continue;
}
roots.push(entry.path());
}
Ok(roots)
}
async fn handle_watch_event(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
gate: &RescanGate,
event: notify::Result<Event>,
) {
let event = match event {
Ok(event) => event,
Err(err) => {
log::warn!("Filesystem watcher event error: {err}");
return;
}
};
let game_dir = ctx.game_dir.read().await.clone();
let ids = event
.paths
.iter()
.filter_map(|path| game_id_from_event_path(&game_dir, path))
.collect::<HashSet<_>>();
for id in ids {
if ctx.active_operations.read().await.contains_key(&id) {
log::debug!("Dropping filesystem event for {id}: operation active");
continue;
}
queue_rescan(ctx, tx_notify_ui, gate, id).await;
}
}
async fn queue_rescan(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
gate: &RescanGate,
id: String,
) {
{
let mut running = gate.running.write().await;
if running.contains(&id) {
gate.pending.write().await.insert(id);
return;
}
running.insert(id.clone());
}
let ctx = ctx.clone();
let tx_notify_ui = tx_notify_ui.clone();
let gate = gate.clone();
ctx.task_tracker.clone().spawn(async move {
run_gated_rescan(ctx, tx_notify_ui, gate, id).await;
});
}
async fn run_gated_rescan(
ctx: Ctx,
tx_notify_ui: UnboundedSender<PeerEvent>,
gate: RescanGate,
id: String,
) {
loop {
gate.pending.write().await.remove(&id);
if ctx.active_operations.read().await.contains_key(&id) {
break;
}
let game_dir = ctx.game_dir.read().await.clone();
let catalog = ctx.catalog.read().await.clone();
match rescan_local_game(&game_dir, &catalog, &id).await {
Ok(scan) => update_and_announce_games(&ctx, &tx_notify_ui, scan).await,
Err(err) => log::error!("Failed to rescan local game {id}: {err}"),
}
if !gate.pending.write().await.remove(&id) {
break;
}
}
gate.running.write().await.remove(&id);
}
async fn run_fallback_scan(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>) {
let game_dir = ctx.game_dir.read().await.clone();
let catalog = ctx.catalog.read().await.clone();
match scan_local_library(&game_dir, &catalog).await {
Ok(scan) => update_and_announce_games(ctx, tx_notify_ui, scan).await,
Err(err) => log::error!("Failed to scan local games directory: {err}"),
}
}
fn game_id_from_event_path(game_dir: &Path, path: &Path) -> Option<String> {
let relative = path.strip_prefix(game_dir).ok()?;
let mut components = relative.components();
let game_id = component_name(components.next()?)?;
if is_ignored_game_root_name(game_id) {
return None;
}
if let Some(second) = components.next().and_then(component_name)
&& should_ignore_game_child(second)
{
return None;
}
Some(game_id.to_string())
}
fn component_name(component: Component<'_>) -> Option<&str> {
match component {
Component::Normal(name) => name.to_str(),
_ => None,
}
}
fn should_ignore_game_child(name: &str) -> bool {
is_local_dir_name(name)
|| name.starts_with(".local.")
|| name.starts_with(".version.ini.")
|| name == ".lanspread"
|| name == ".lanspread.json"
|| name == ".sync"
|| name == ".softlan_game_installed"
}
#[cfg(test)]
mod tests {
use super::{game_id_from_event_path, should_ignore_game_child};
#[test]
fn event_paths_map_to_top_level_game_id() {
let root = std::path::Path::new("/games");
assert_eq!(
game_id_from_event_path(root, std::path::Path::new("/games/aoe2/version.ini"))
.as_deref(),
Some("aoe2")
);
assert_eq!(
game_id_from_event_path(root, std::path::Path::new("/games/aoe2/local/save.dat")),
None
);
assert_eq!(
game_id_from_event_path(root, std::path::Path::new("/games/.lanspread/index.json")),
None
);
}
#[test]
fn event_ignore_list_covers_reserved_names() {
for name in [
"local",
".local.installing",
".local.backup",
".version.ini.tmp",
".version.ini.discarded",
".lanspread",
".lanspread.json",
".sync",
".softlan_game_installed",
] {
assert!(should_ignore_game_child(name));
}
assert!(!should_ignore_game_child("version.ini"));
assert!(!should_ignore_game_child("game.eti"));
}
}