fix(peer): refresh settled install state after operations
The follow-up review found a few stale lifecycle edges around local game transactions. Recovery could sweep active roots, post-operation refreshes still re-ran full startup recovery, and the UI kept inferring local-only state from downloaded and installed flags instead of the backend availability. This updates the peer lifecycle so startup recovery skips active operations, install/update/uninstall refresh only the affected game after the operation guard is dropped, and path-changing game-directory updates are rejected while operations are active. It also removes the dead UpdateGame command, drops the unused manifest_hash write field while preserving old JSON reads, renames the internal install-finished event, and carries availability through the DB, peer summaries, Tauri refreshes, and the React model. The included follow-up documents record the review source, implementation decisions, and the remaining FOLLOW_UP_2.md work so later commits can stay small instead of reopening the completed plan items. Test Plan: - git diff --check - just fmt - just clippy - just test Follow-up-Plan: FOLLOW_UP_PLAN.md
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
io::ErrorKind,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
@@ -115,7 +116,7 @@ pub async fn uninstall(game_root: &Path, id: &str) -> eyre::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn recover_on_startup(game_dir: &Path) -> eyre::Result<()> {
|
||||
pub async fn recover_on_startup(game_dir: &Path, active_ids: &HashSet<String>) -> eyre::Result<()> {
|
||||
recover_download_transients(game_dir).await?;
|
||||
|
||||
let mut entries = match tokio::fs::read_dir(game_dir).await {
|
||||
@@ -135,6 +136,10 @@ pub async fn recover_on_startup(game_dir: &Path) -> eyre::Result<()> {
|
||||
if id == ".lanspread" {
|
||||
continue;
|
||||
}
|
||||
if active_ids.contains(&id) {
|
||||
log::debug!("Skipping recovery for active game root {id}");
|
||||
continue;
|
||||
}
|
||||
|
||||
recover_game_root(&entry.path(), &id).await?;
|
||||
}
|
||||
@@ -457,6 +462,7 @@ impl From<bool> for FsEntryState {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
@@ -551,6 +557,29 @@ mod tests {
|
||||
assert_eq!(intent.state, InstallIntentState::None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_unpacks_multiple_root_eti_archives_in_sorted_order() {
|
||||
let temp = TempDir::new();
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join("b.eti"), b"archive");
|
||||
write_file(&root.join("a.eti"), b"archive");
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
let unpacker = Arc::new(FakeUnpacker::default());
|
||||
|
||||
install(&root, "game", unpacker.clone())
|
||||
.await
|
||||
.expect("install should succeed");
|
||||
|
||||
let archives = unpacker
|
||||
.archives
|
||||
.lock()
|
||||
.expect("archive list should not be poisoned")
|
||||
.iter()
|
||||
.filter_map(|path| path.file_name()?.to_str().map(ToOwned::to_owned))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(archives, vec!["a.eti", "b.eti"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_failure_restores_previous_local() {
|
||||
let temp = TempDir::new();
|
||||
@@ -571,6 +600,26 @@ mod tests {
|
||||
assert_eq!(intent.state, InstallIntentState::None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_success_promotes_new_local_and_removes_backup() {
|
||||
let temp = TempDir::new();
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join("game.eti"), b"archive");
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
write_file(&root.join("local").join("old.txt"), b"old");
|
||||
|
||||
update(&root, "game", successful_unpacker())
|
||||
.await
|
||||
.expect("update should succeed");
|
||||
|
||||
assert!(root.join("local").join("payload.txt").is_file());
|
||||
assert!(!root.join("local").join("old.txt").exists());
|
||||
assert!(!root.join(".local.installing").exists());
|
||||
assert!(!root.join(".local.backup").exists());
|
||||
let intent = read_intent(&root, "game").await;
|
||||
assert_eq!(intent.state, InstallIntentState::None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uninstall_removes_only_local_install() {
|
||||
let temp = TempDir::new();
|
||||
@@ -648,4 +697,20 @@ mod tests {
|
||||
assert!(!root.join(VERSION_TMP_FILE).exists());
|
||||
assert!(!root.join(VERSION_DISCARDED_FILE).exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_recovery_skips_active_game_roots() {
|
||||
let temp = TempDir::new();
|
||||
let active_root = temp.0.join("active");
|
||||
let inactive_root = temp.0.join("inactive");
|
||||
write_file(&active_root.join(VERSION_TMP_FILE), b"tmp");
|
||||
write_file(&inactive_root.join(VERSION_TMP_FILE), b"tmp");
|
||||
|
||||
recover_on_startup(&temp.0, &HashSet::from(["active".to_string()]))
|
||||
.await
|
||||
.expect("recovery should succeed");
|
||||
|
||||
assert!(active_root.join(VERSION_TMP_FILE).is_file());
|
||||
assert!(!inactive_root.join(VERSION_TMP_FILE).exists());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user