fix(peer): repair update lifecycle regressions
FINDINGS.md identified three merge blockers in the post-plan install/update flow. Updates now use FetchLatestFromPeers so the Tauri update command bypasses local manifest serving and asks peers that advertise the latest version for fresh file metadata. PeerGameDB now aggregates and validates file descriptions from latest-version peers, keeping stale cached metadata for older versions from poisoning chunk planning when filenames stay the same but sizes change. Download-to-install handoff now performs explicit async state transitions. The download task mutates Downloading to Installing or Updating under the active-operation write lock, clears the cancellation token, and then runs the install transaction. OperationGuard remains armed only as crash or abort cleanup and is disarmed after normal explicit cleanup, so final refreshes no longer race a deferred Drop cleanup. Local library index writers now serialize the load/mutate/save window with one async mutex. The index fingerprint also includes the root version.ini contents so a same-length version rewrite in the same mtime second still updates the reported local version. The tradeoff is that local index mutations are serialized in-process instead of moved into a dedicated actor. That keeps the fix small and scoped to the merge blockers while preserving the existing scanner API. Test Plan: - just fmt - just test - just clippy - just build - git diff --check Refs: - FINDINGS.md
This commit is contained in:
@@ -5,13 +5,14 @@ use std::{
|
||||
hash::{Hash, Hasher},
|
||||
io::ErrorKind,
|
||||
path::{Path, PathBuf},
|
||||
sync::LazyLock,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use lanspread_db::db::{Game, GameDB, GameFileDescription};
|
||||
use lanspread_proto::{Availability, GameSummary};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::{io::AsyncWriteExt, sync::Mutex};
|
||||
|
||||
use crate::{context::OperationKind, error::PeerError};
|
||||
|
||||
@@ -76,6 +77,8 @@ const INTENT_LOG_FILE: &str = ".lanspread.json";
|
||||
const VERSION_TMP_FILE: &str = ".version.ini.tmp";
|
||||
const VERSION_DISCARDED_FILE: &str = ".version.ini.discarded";
|
||||
|
||||
static LIBRARY_INDEX_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct LibraryIndex {
|
||||
revision: u64,
|
||||
@@ -92,6 +95,8 @@ struct GameIndexEntry {
|
||||
struct GameFingerprint {
|
||||
eti_files: Vec<EtiFingerprint>,
|
||||
version_mtime: Option<u64>,
|
||||
#[serde(default)]
|
||||
version_contents: Option<String>,
|
||||
local_dir_present: bool,
|
||||
}
|
||||
|
||||
@@ -245,9 +250,21 @@ async fn fingerprint_game_dir(game_path: &Path) -> eyre::Result<GameFingerprint>
|
||||
let eti_files = root_eti_fingerprints(game_path).await?;
|
||||
|
||||
let version_path = game_path.join("version.ini");
|
||||
let version_mtime = match tokio::fs::metadata(&version_path).await {
|
||||
Ok(metadata) if metadata.is_file() => metadata.modified().ok().map(system_time_to_secs),
|
||||
Err(_) | Ok(_) => None,
|
||||
let (version_mtime, version_contents) = match tokio::fs::metadata(&version_path).await {
|
||||
Ok(metadata) if metadata.is_file() => {
|
||||
let contents = match tokio::fs::read_to_string(&version_path).await {
|
||||
Ok(contents) => Some(contents.trim().to_string()),
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to read {} for fingerprinting: {err}",
|
||||
version_path.display()
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
(metadata.modified().ok().map(system_time_to_secs), contents)
|
||||
}
|
||||
Err(_) | Ok(_) => (None, None),
|
||||
};
|
||||
|
||||
let local_dir_present = local_dir_is_directory(game_path).await;
|
||||
@@ -255,6 +272,7 @@ async fn fingerprint_game_dir(game_path: &Path) -> eyre::Result<GameFingerprint>
|
||||
Ok(GameFingerprint {
|
||||
eti_files,
|
||||
version_mtime,
|
||||
version_contents,
|
||||
local_dir_present,
|
||||
})
|
||||
}
|
||||
@@ -558,6 +576,7 @@ pub async fn scan_local_library(
|
||||
return Ok(empty_scan());
|
||||
}
|
||||
|
||||
let _index_guard = LIBRARY_INDEX_LOCK.lock().await;
|
||||
let index_path = library_index_path(game_path);
|
||||
let mut index = load_library_index(&index_path).await;
|
||||
let mut seen_ids = HashSet::new();
|
||||
@@ -621,6 +640,7 @@ pub async fn rescan_local_game(
|
||||
game_id: &str,
|
||||
) -> eyre::Result<LocalLibraryScan> {
|
||||
let game_path = game_dir.as_ref();
|
||||
let _index_guard = LIBRARY_INDEX_LOCK.lock().await;
|
||||
let index_path = library_index_path(game_path);
|
||||
let mut index = load_library_index(&index_path).await;
|
||||
|
||||
@@ -688,6 +708,7 @@ mod tests {
|
||||
fingerprint: GameFingerprint {
|
||||
eti_files: Vec::new(),
|
||||
version_mtime: Some(manifest_hash),
|
||||
version_contents: Some("20250101".to_string()),
|
||||
local_dir_present: false,
|
||||
},
|
||||
},
|
||||
@@ -827,6 +848,48 @@ mod tests {
|
||||
assert_eq!(ready.availability, Availability::Ready);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_rescans_preserve_both_index_updates() {
|
||||
let temp = TempDir::new("lanspread-local-games-concurrent");
|
||||
let catalog = HashSet::from(["game-a".to_string(), "game-b".to_string()]);
|
||||
write_file(&temp.path().join("game-a").join("version.ini"), b"20250101");
|
||||
write_file(&temp.path().join("game-b").join("version.ini"), b"20250101");
|
||||
|
||||
let initial = scan_local_library(temp.path(), &catalog)
|
||||
.await
|
||||
.expect("initial scan should succeed");
|
||||
assert_eq!(initial.revision, 1);
|
||||
|
||||
write_file(&temp.path().join("game-a").join("game-a.eti"), b"archive-a");
|
||||
write_file(&temp.path().join("game-b").join("game-b.eti"), b"archive-b");
|
||||
|
||||
let (scan_a, scan_b) = tokio::join!(
|
||||
rescan_local_game(temp.path(), &catalog, "game-a"),
|
||||
rescan_local_game(temp.path(), &catalog, "game-b")
|
||||
);
|
||||
scan_a.expect("game-a rescan should succeed");
|
||||
scan_b.expect("game-b rescan should succeed");
|
||||
|
||||
let index = load_library_index(&library_index_path(temp.path())).await;
|
||||
assert_eq!(index.revision, 3);
|
||||
let game_a = index
|
||||
.games
|
||||
.get("game-a")
|
||||
.expect("game-a update should remain in index");
|
||||
let game_b = index
|
||||
.games
|
||||
.get("game-b")
|
||||
.expect("game-b update should remain in index");
|
||||
assert!(
|
||||
game_a.summary.size > 8,
|
||||
"game-a rescan should persist the new archive"
|
||||
);
|
||||
assert!(
|
||||
game_b.summary.size > 8,
|
||||
"game-b rescan should persist the new archive"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_download_available_gates_on_catalog_operation_and_sentinel() {
|
||||
let temp = TempDir::new("lanspread-local-games");
|
||||
|
||||
Reference in New Issue
Block a user