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:
2026-05-16 14:19:10 +02:00
parent 8890d78642
commit 6242d64583
9 changed files with 722 additions and 74 deletions
+79 -2
View File
@@ -431,11 +431,28 @@ impl PeerGameDB {
.collect()
}
/// Returns file descriptions from peers that advertise the latest game version.
#[must_use]
pub fn latest_game_files_for(
&self,
game_id: &str,
) -> Vec<(SocketAddr, Vec<GameFileDescription>)> {
let latest_peers = self.peers_with_latest_version(game_id);
if latest_peers.is_empty() {
return Vec::new();
}
self.game_files_for(game_id)
.into_iter()
.filter(|(addr, _)| latest_peers.contains(addr))
.collect()
}
/// Returns aggregated file descriptions for a game across all peers.
#[must_use]
pub fn aggregated_game_files(&self, game_id: &str) -> Vec<GameFileDescription> {
let mut seen: HashMap<String, GameFileDescription> = HashMap::new();
for (_, files) in self.game_files_for(game_id) {
for (_, files) in self.latest_game_files_for(game_id) {
for file in files {
seen.entry(file.relative_path.clone()).or_insert(file);
}
@@ -477,7 +494,7 @@ impl PeerGameDB {
&self,
game_id: &str,
) -> eyre::Result<MajorityValidationResult> {
let game_files = self.game_files_for(game_id);
let game_files = self.latest_game_files_for(game_id);
if game_files.is_empty() {
return Ok((Vec::new(), Vec::new(), HashMap::new()));
}
@@ -777,6 +794,15 @@ mod tests {
}
}
fn file_desc(game_id: &str, relative_path: &str, size: u64) -> GameFileDescription {
GameFileDescription {
game_id: game_id.to_string(),
relative_path: relative_path.to_string(),
is_dir: false,
size,
}
}
#[test]
fn aggregation_counts_only_ready_peers_as_download_sources() {
let ready_addr = addr(12000);
@@ -828,4 +854,55 @@ mod tests {
assert_eq!(db.get_latest_version_for_game("game"), None);
assert!(db.peers_with_latest_version("game").is_empty());
}
#[test]
fn validation_uses_latest_version_file_metadata() {
let old_addr = addr(12003);
let new_addr = addr(12004);
let mut db = PeerGameDB::new();
db.upsert_peer("old".to_string(), old_addr);
db.upsert_peer("new".to_string(), new_addr);
db.update_peer_games(
&"old".to_string(),
vec![summary("game", "20240101", Availability::Ready)],
);
db.update_peer_games(
&"new".to_string(),
vec![summary("game", "20250101", Availability::Ready)],
);
db.update_peer_game_files(
&"old".to_string(),
"game",
vec![
file_desc("game", "game/version.ini", 8),
file_desc("game", "game/archive.eti", 10),
],
);
db.update_peer_game_files(
&"new".to_string(),
"game",
vec![
file_desc("game", "game/version.ini", 8),
file_desc("game", "game/archive.eti", 20),
],
);
let aggregated = db.aggregated_game_files("game");
let archive = aggregated
.iter()
.find(|desc| desc.relative_path == "game/archive.eti")
.expect("latest archive should be present");
assert_eq!(archive.size, 20);
let (validated, peers, file_peer_map) = db
.validate_file_sizes_majority("game")
.expect("old-version file metadata should not create ambiguity");
assert_eq!(peers, vec![new_addr]);
let archive = validated
.iter()
.find(|desc| desc.relative_path == "game/archive.eti")
.expect("latest archive should validate");
assert_eq!(archive.size, 20);
assert_eq!(file_peer_map.get("game/archive.eti"), Some(&vec![new_addr]));
}
}