diff --git a/crates/lanspread-peer/src/local_games.rs b/crates/lanspread-peer/src/local_games.rs index e5e695c..39d5993 100644 --- a/crates/lanspread-peer/src/local_games.rs +++ b/crates/lanspread-peer/src/local_games.rs @@ -18,6 +18,7 @@ use lanspread_db::db::{ }; use lanspread_proto::{Availability, GameSummary}; use serde::{Deserialize, Serialize}; +use tokio::io::AsyncWriteExt; use crate::{context::OperationKind, error::PeerError}; @@ -119,7 +120,34 @@ fn library_index_path(game_dir: &Path) -> PathBuf { game_dir.join(LIBRARY_INDEX_DIR).join(LIBRARY_INDEX_FILE) } +fn library_index_tmp_path(path: &Path) -> PathBuf { + let Some(file_name) = path.file_name() else { + return path.with_extension("tmp"); + }; + + let mut tmp_name = file_name.to_os_string(); + tmp_name.push(".tmp"); + path.with_file_name(tmp_name) +} + +async fn sweep_stale_library_index_tmp(path: &Path) { + let tmp_path = library_index_tmp_path(path); + match tokio::fs::remove_file(&tmp_path).await { + Ok(()) => log::debug!( + "Removed stale library index temp file {}", + tmp_path.display() + ), + Err(err) if err.kind() == ErrorKind::NotFound => {} + Err(err) => log::warn!( + "Failed to remove stale library index temp file {}: {err}", + tmp_path.display() + ), + } +} + async fn load_library_index(path: &Path) -> LibraryIndex { + sweep_stale_library_index_tmp(path).await; + let data = match tokio::fs::read_to_string(path).await { Ok(data) => data, Err(err) => { @@ -150,7 +178,28 @@ async fn save_library_index(path: &Path, index: &LibraryIndex) -> eyre::Result<( tokio::fs::create_dir_all(parent).await?; } let data = serde_json::to_vec_pretty(index)?; - tokio::fs::write(path, data).await?; + let tmp_path = library_index_tmp_path(path); + + let mut file = tokio::fs::File::create(&tmp_path).await?; + file.write_all(&data).await?; + file.sync_all().await?; + drop(file); + + tokio::fs::rename(&tmp_path, path).await?; + sync_parent_dir(path)?; + Ok(()) +} + +#[cfg(unix)] +fn sync_parent_dir(path: &Path) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::File::open(parent)?.sync_all()?; + } + Ok(()) +} + +#[cfg(not(unix))] +fn sync_parent_dir(_path: &Path) -> std::io::Result<()> { Ok(()) } @@ -635,6 +684,78 @@ mod tests { std::fs::write(path, bytes).expect("file should be written"); } + fn test_library_index(revision: u64, id: &str, manifest_hash: u64) -> LibraryIndex { + LibraryIndex { + revision, + games: HashMap::from([( + id.to_string(), + GameIndexEntry { + summary: GameSummary { + id: id.to_string(), + name: id.to_string(), + size: manifest_hash, + downloaded: true, + installed: false, + eti_version: Some("20250101".to_string()), + manifest_hash, + availability: Availability::Ready, + }, + fingerprint: GameFingerprint { + eti_files: Vec::new(), + version_mtime: Some(manifest_hash), + local_dir_present: false, + }, + }, + )]), + } + } + + #[tokio::test] + async fn save_library_index_is_atomic_on_replace() { + let temp = TempDir::new("lanspread-local-games"); + let index_path = library_index_path(temp.path()); + let tmp_path = library_index_tmp_path(&index_path); + + let first = test_library_index(1, "game-a", 11); + save_library_index(&index_path, &first) + .await + .expect("first index write should succeed"); + assert!(!tmp_path.exists()); + + let second = test_library_index(2, "game-b", 22); + save_library_index(&index_path, &second) + .await + .expect("replacement index write should succeed"); + + assert!(!tmp_path.exists()); + let loaded = load_library_index(&index_path).await; + assert_eq!(loaded.revision, 2); + assert!(!loaded.games.contains_key("game-a")); + let game_b = loaded + .games + .get("game-b") + .expect("replacement index should be persisted"); + assert_eq!(game_b.summary.manifest_hash, 22); + } + + #[tokio::test] + async fn load_library_index_sweeps_stale_tmp() { + let temp = TempDir::new("lanspread-local-games"); + let index_path = library_index_path(temp.path()); + let tmp_path = library_index_tmp_path(&index_path); + let canonical = test_library_index(7, "game", 77); + let data = serde_json::to_vec_pretty(&canonical).expect("index should serialize"); + + write_file(&index_path, &data); + write_file(&tmp_path, b"{ not json"); + + let loaded = load_library_index(&index_path).await; + + assert_eq!(loaded.revision, 7); + assert!(loaded.games.contains_key("game")); + assert!(!tmp_path.exists()); + } + #[tokio::test] async fn scan_uses_version_ini_and_local_dir_as_independent_state() { let temp = TempDir::new("lanspread-local-games");