fix(peer): write local library index atomically

The local library index used tokio::fs::write directly on the canonical
library_index.json path. That truncates the existing index before writing the
new bytes, so a crash or power loss could leave a zero-length or partial cache.

Write the index through a sibling temp file, sync it, rename it over the
canonical path, and sync the parent directory on Unix. Loading the index also
sweeps a stale temp file before parsing the canonical file. That keeps the
existing cache valid after an interrupted write while still letting a normal
scan rebuild from disk if the canonical index is missing or corrupt.

This follows the existing temp-plus-rename pattern used for version.ini and
install intents. It intentionally does not add locking; local library writes
are already serialized by the peer operation flow.

Test Plan:
just fmt
just test
just clippy

Refs: none
This commit is contained in:
2026-05-16 10:01:34 +02:00
parent cc805777d8
commit fdad162240
+122 -1
View File
@@ -18,6 +18,7 @@ use lanspread_db::db::{
}; };
use lanspread_proto::{Availability, GameSummary}; use lanspread_proto::{Availability, GameSummary};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::io::AsyncWriteExt;
use crate::{context::OperationKind, error::PeerError}; 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) 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 { 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 { let data = match tokio::fs::read_to_string(path).await {
Ok(data) => data, Ok(data) => data,
Err(err) => { Err(err) => {
@@ -150,7 +178,28 @@ async fn save_library_index(path: &Path, index: &LibraryIndex) -> eyre::Result<(
tokio::fs::create_dir_all(parent).await?; tokio::fs::create_dir_all(parent).await?;
} }
let data = serde_json::to_vec_pretty(index)?; 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(()) Ok(())
} }
@@ -635,6 +684,78 @@ mod tests {
std::fs::write(path, bytes).expect("file should be written"); 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] #[tokio::test]
async fn scan_uses_version_ini_and_local_dir_as_independent_state() { async fn scan_uses_version_ini_and_local_dir_as_independent_state() {
let temp = TempDir::new("lanspread-local-games"); let temp = TempDir::new("lanspread-local-games");