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:
2026-05-16 08:50:51 +02:00
parent fce34c7bd2
commit b5d20c1e72
22 changed files with 1389 additions and 131 deletions
+53 -2
View File
@@ -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());
}
}