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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user