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:
@@ -25,7 +25,6 @@ pub struct InstallIntent {
|
||||
pub recorded_at: u64,
|
||||
pub state: InstallIntentState,
|
||||
pub eti_version: Option<String>,
|
||||
pub manifest_hash: Option<u64>,
|
||||
}
|
||||
|
||||
impl InstallIntent {
|
||||
@@ -36,7 +35,6 @@ impl InstallIntent {
|
||||
recorded_at: now_unix_secs(),
|
||||
state,
|
||||
eti_version,
|
||||
manifest_hash: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,4 +191,57 @@ mod tests {
|
||||
let recovered = read_intent(temp.path(), "game").await;
|
||||
assert_eq!(recovered.state, InstallIntentState::None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mismatched_id_is_treated_as_missing() {
|
||||
let temp = TempDir::new();
|
||||
tokio::fs::write(
|
||||
intent_path(temp.path()),
|
||||
r#"{"schema_version":1,"id":"other","recorded_at":0,"state":"Updating"}"#,
|
||||
)
|
||||
.await
|
||||
.expect("intent should be written");
|
||||
|
||||
let recovered = read_intent(temp.path(), "game").await;
|
||||
assert_eq!(recovered.state, InstallIntentState::None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn corrupt_intent_is_treated_as_missing() {
|
||||
let temp = TempDir::new();
|
||||
tokio::fs::write(intent_path(temp.path()), b"not json")
|
||||
.await
|
||||
.expect("intent should be written");
|
||||
|
||||
let recovered = read_intent(temp.path(), "game").await;
|
||||
assert_eq!(recovered.state, InstallIntentState::None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn old_manifest_hash_field_is_ignored_and_new_writes_omit_it() {
|
||||
let temp = TempDir::new();
|
||||
tokio::fs::write(
|
||||
intent_path(temp.path()),
|
||||
r#"{"schema_version":1,"id":"game","recorded_at":0,"state":"Updating","eti_version":"20240101","manifest_hash":42}"#,
|
||||
)
|
||||
.await
|
||||
.expect("intent should be written");
|
||||
|
||||
let recovered = read_intent(temp.path(), "game").await;
|
||||
assert_eq!(recovered.state, InstallIntentState::Updating);
|
||||
assert_eq!(recovered.eti_version.as_deref(), Some("20240101"));
|
||||
|
||||
write_intent(temp.path(), &InstallIntent::none("game", None))
|
||||
.await
|
||||
.expect("intent should be written");
|
||||
let written = tokio::fs::read_to_string(intent_path(temp.path()))
|
||||
.await
|
||||
.expect("intent should be readable");
|
||||
assert!(
|
||||
serde_json::from_str::<serde_json::Value>(&written)
|
||||
.expect("intent should parse")
|
||||
.get("manifest_hash")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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