Files
lanspread/crates/lanspread-peer/src/install/transaction.rs
T
ddidderr b5d20c1e72 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
2026-05-16 08:50:51 +02:00

717 lines
22 KiB
Rust

use std::{
collections::HashSet,
io::ErrorKind,
path::{Path, PathBuf},
sync::Arc,
};
use eyre::WrapErr;
use super::{
intent::{InstallIntent, InstallIntentState, read_intent, write_intent},
unpack::Unpacker,
};
use crate::local_games::version_ini_is_regular_file;
const LOCAL_DIR: &str = "local";
const INSTALLING_DIR: &str = ".local.installing";
const BACKUP_DIR: &str = ".local.backup";
const OWNED_MARKER: &str = ".lanspread_owned";
const VERSION_TMP_FILE: &str = ".version.ini.tmp";
const VERSION_DISCARDED_FILE: &str = ".version.ini.discarded";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum FsEntryState {
Present,
Missing,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct InstallFsState {
local: FsEntryState,
installing: FsEntryState,
backup: FsEntryState,
}
pub async fn install(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
let eti_version = read_downloaded_version(game_root).await;
write_intent(
game_root,
&InstallIntent::new(id, InstallIntentState::Installing, eti_version.clone()),
)
.await?;
let result = install_inner(game_root, id, unpacker).await;
match result {
Ok(()) => {
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
Ok(())
}
Err(err) => {
if let Err(cleanup_err) = remove_dir_all_if_exists(&installing_dir(game_root)).await {
log::warn!(
"Failed to clean install staging {}: {cleanup_err}",
installing_dir(game_root).display()
);
}
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
Err(err)
}
}
}
pub async fn update(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
let eti_version = read_downloaded_version(game_root).await;
write_intent(
game_root,
&InstallIntent::new(id, InstallIntentState::Updating, eti_version.clone()),
)
.await?;
let result = update_inner(game_root, id, unpacker).await;
match result {
Ok(()) => {
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
if let Err(err) = remove_dir_all_if_exists(&backup_dir(game_root)).await {
log::warn!(
"Failed to clean install backup {}: {err}",
backup_dir(game_root).display()
);
}
Ok(())
}
Err(err) => {
let rollback = rollback_update(game_root).await;
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
if let Err(rollback_err) = rollback {
return Err(err.wrap_err(format!("rollback also failed: {rollback_err}")));
}
Err(err)
}
}
}
pub async fn uninstall(game_root: &Path, id: &str) -> eyre::Result<()> {
let eti_version = read_downloaded_version(game_root).await;
write_intent(
game_root,
&InstallIntent::new(id, InstallIntentState::Uninstalling, eti_version.clone()),
)
.await?;
let result = uninstall_inner(game_root).await;
match result {
Ok(()) => {
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
Ok(())
}
Err(err) => {
let rollback = restore_backup(game_root).await;
if let Err(rollback_err) = rollback {
return Err(err.wrap_err(format!("rollback also failed: {rollback_err}")));
}
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
Err(err)
}
}
}
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 {
Ok(entries) => entries,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(err.into()),
};
while let Some(entry) = entries.next_entry().await? {
if !entry.file_type().await?.is_dir() {
continue;
}
let Some(id) = entry.file_name().to_str().map(ToOwned::to_owned) else {
continue;
};
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?;
}
Ok(())
}
pub async fn recover_game_root(game_root: &Path, id: &str) -> eyre::Result<()> {
recover_download_transients(game_root).await?;
let intent = read_intent(game_root, id).await;
let fs = inspect_install_fs(game_root).await;
match intent.state {
InstallIntentState::None => recover_none_intent(game_root).await?,
InstallIntentState::Installing => recover_installing(game_root, id, intent, fs).await?,
InstallIntentState::Updating => recover_updating(game_root, id, intent, fs).await?,
InstallIntentState::Uninstalling => recover_uninstalling(game_root, id, intent, fs).await?,
}
Ok(())
}
async fn install_inner(
game_root: &Path,
id: &str,
unpacker: Arc<dyn Unpacker>,
) -> eyre::Result<()> {
let local = local_dir(game_root);
if path_is_dir(&local).await {
eyre::bail!("game {id} is already installed");
}
let staging = installing_dir(game_root);
prepare_owned_empty_dir(&staging).await?;
unpack_archives(game_root, &staging, unpacker).await?;
tokio::fs::rename(&staging, &local)
.await
.wrap_err_with(|| format!("failed to promote install for {id}"))?;
Ok(())
}
async fn update_inner(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
let local = local_dir(game_root);
let backup = backup_dir(game_root);
let staging = installing_dir(game_root);
if !path_is_dir(&local).await {
eyre::bail!("game {id} is not installed");
}
prepare_backup_slot(&backup).await?;
tokio::fs::rename(&local, &backup)
.await
.wrap_err_with(|| format!("failed to move existing install for {id} to backup"))?;
drop_owned_marker(&backup).await?;
prepare_owned_empty_dir(&staging).await?;
unpack_archives(game_root, &staging, unpacker).await?;
tokio::fs::rename(&staging, &local)
.await
.wrap_err_with(|| format!("failed to promote update for {id}"))?;
Ok(())
}
async fn uninstall_inner(game_root: &Path) -> eyre::Result<()> {
let local = local_dir(game_root);
let backup = backup_dir(game_root);
if !path_is_dir(&local).await {
return Ok(());
}
prepare_backup_slot(&backup).await?;
tokio::fs::rename(&local, &backup).await?;
drop_owned_marker(&backup).await?;
tokio::fs::remove_dir_all(&backup).await?;
Ok(())
}
async fn unpack_archives(
game_root: &Path,
staging: &Path,
unpacker: Arc<dyn Unpacker>,
) -> eyre::Result<()> {
let archives = root_eti_archives(game_root).await?;
if archives.is_empty() {
eyre::bail!("no .eti archives found in {}", game_root.display());
}
for archive in archives {
unpacker.unpack(&archive, staging).await?;
}
Ok(())
}
async fn root_eti_archives(game_root: &Path) -> eyre::Result<Vec<PathBuf>> {
let mut entries = tokio::fs::read_dir(game_root).await?;
let mut archives = Vec::new();
while let Some(entry) = entries.next_entry().await? {
if !entry.file_type().await?.is_file() {
continue;
}
let path = entry.path();
if path.extension().is_some_and(|extension| extension == "eti") {
archives.push(path);
}
}
archives.sort();
Ok(archives)
}
async fn recover_none_intent(game_root: &Path) -> eyre::Result<()> {
sweep_owned_orphan(&installing_dir(game_root)).await?;
sweep_owned_orphan(&backup_dir(game_root)).await?;
Ok(())
}
async fn recover_installing(
game_root: &Path,
id: &str,
intent: InstallIntent,
fs: InstallFsState,
) -> eyre::Result<()> {
if let InstallFsState {
installing: FsEntryState::Present,
..
} = fs
{
remove_dir_all_if_exists(&installing_dir(game_root)).await?;
}
write_intent(game_root, &InstallIntent::none(id, intent.eti_version)).await
}
async fn recover_updating(
game_root: &Path,
id: &str,
intent: InstallIntent,
fs: InstallFsState,
) -> eyre::Result<()> {
match fs {
InstallFsState {
local: FsEntryState::Missing,
installing: FsEntryState::Present,
backup: FsEntryState::Present,
} => {
remove_dir_all_if_exists(&installing_dir(game_root)).await?;
restore_backup(game_root).await?;
}
InstallFsState {
local: FsEntryState::Present,
installing: FsEntryState::Present,
backup: FsEntryState::Present,
} => {
remove_dir_all_if_exists(&installing_dir(game_root)).await?;
remove_dir_all_if_exists(&backup_dir(game_root)).await?;
}
InstallFsState {
local: FsEntryState::Present,
installing: FsEntryState::Missing,
backup: FsEntryState::Present,
} => remove_dir_all_if_exists(&backup_dir(game_root)).await?,
_ => {}
}
write_intent(game_root, &InstallIntent::none(id, intent.eti_version)).await
}
async fn recover_uninstalling(
game_root: &Path,
id: &str,
intent: InstallIntent,
fs: InstallFsState,
) -> eyre::Result<()> {
match fs {
InstallFsState {
local: FsEntryState::Missing,
installing: FsEntryState::Missing,
backup: FsEntryState::Present,
} => remove_dir_all_if_exists(&backup_dir(game_root)).await?,
InstallFsState {
local: FsEntryState::Present,
installing: FsEntryState::Missing,
backup: FsEntryState::Missing,
} => uninstall_inner(game_root).await?,
_ => {}
}
write_intent(game_root, &InstallIntent::none(id, intent.eti_version)).await
}
async fn recover_download_transients(root: &Path) -> eyre::Result<()> {
remove_file_if_exists(&root.join(VERSION_TMP_FILE)).await?;
remove_file_if_exists(&root.join(VERSION_DISCARDED_FILE)).await?;
Ok(())
}
async fn inspect_install_fs(game_root: &Path) -> InstallFsState {
InstallFsState {
local: path_is_dir(&local_dir(game_root)).await.into(),
installing: path_is_dir(&installing_dir(game_root)).await.into(),
backup: path_is_dir(&backup_dir(game_root)).await.into(),
}
}
async fn read_downloaded_version(game_root: &Path) -> Option<String> {
if !version_ini_is_regular_file(game_root).await {
return None;
}
match lanspread_db::db::read_version_from_ini(game_root) {
Ok(version) => version,
Err(err) => {
log::warn!(
"Failed to read version.ini in {}: {err}",
game_root.display()
);
None
}
}
}
async fn prepare_owned_empty_dir(path: &Path) -> eyre::Result<()> {
if path.exists() {
if owned_marker(path).is_file() {
tokio::fs::remove_dir_all(path).await?;
} else {
eyre::bail!("refusing to reuse markerless directory {}", path.display());
}
}
tokio::fs::create_dir_all(path).await?;
drop_owned_marker(path).await
}
async fn prepare_backup_slot(path: &Path) -> eyre::Result<()> {
if !path.exists() {
return Ok(());
}
if owned_marker(path).is_file() {
tokio::fs::remove_dir_all(path).await?;
return Ok(());
}
eyre::bail!("refusing to replace markerless backup {}", path.display());
}
async fn drop_owned_marker(path: &Path) -> eyre::Result<()> {
tokio::fs::write(owned_marker(path), []).await?;
Ok(())
}
async fn sweep_owned_orphan(path: &Path) -> eyre::Result<()> {
if !path.exists() {
return Ok(());
}
if owned_marker(path).is_file() {
remove_dir_all_if_exists(path).await?;
} else {
log::warn!(
"Leaving markerless reserved directory untouched: {}",
path.display()
);
}
Ok(())
}
async fn rollback_update(game_root: &Path) -> eyre::Result<()> {
remove_dir_all_if_exists(&installing_dir(game_root)).await?;
restore_backup(game_root).await
}
async fn restore_backup(game_root: &Path) -> eyre::Result<()> {
let local = local_dir(game_root);
let backup = backup_dir(game_root);
if !path_is_dir(&backup).await {
return Ok(());
}
remove_dir_all_if_exists(&local).await?;
tokio::fs::rename(&backup, &local).await?;
Ok(())
}
async fn remove_file_if_exists(path: &Path) -> eyre::Result<()> {
match tokio::fs::remove_file(path).await {
Ok(()) => Ok(()),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
Err(err) => Err(err.into()),
}
}
async fn remove_dir_all_if_exists(path: &Path) -> eyre::Result<()> {
match tokio::fs::remove_dir_all(path).await {
Ok(()) => Ok(()),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
Err(err) => Err(err.into()),
}
}
async fn path_is_dir(path: &Path) -> bool {
tokio::fs::metadata(path)
.await
.is_ok_and(|metadata| metadata.is_dir())
}
fn local_dir(game_root: &Path) -> PathBuf {
game_root.join(LOCAL_DIR)
}
fn installing_dir(game_root: &Path) -> PathBuf {
game_root.join(INSTALLING_DIR)
}
fn backup_dir(game_root: &Path) -> PathBuf {
game_root.join(BACKUP_DIR)
}
fn owned_marker(path: &Path) -> PathBuf {
path.join(OWNED_MARKER)
}
impl From<bool> for FsEntryState {
fn from(value: bool) -> Self {
if value { Self::Present } else { Self::Missing }
}
}
#[cfg(test)]
mod tests {
use std::{
collections::HashSet,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use super::*;
use crate::install::unpack::UnpackFuture;
#[derive(Default)]
struct FakeUnpacker {
fail: bool,
archives: Mutex<Vec<PathBuf>>,
}
impl FakeUnpacker {
fn failing() -> Self {
Self {
fail: true,
archives: Mutex::new(Vec::new()),
}
}
}
impl Unpacker for FakeUnpacker {
fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
Box::pin(async move {
self.archives
.lock()
.expect("archive list should not be poisoned")
.push(archive.to_path_buf());
if self.fail {
eyre::bail!("forced unpack failure");
}
tokio::fs::write(dest.join("payload.txt"), b"installed").await?;
Ok(())
})
}
}
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let mut path = std::env::temp_dir();
path.push(format!(
"lanspread-install-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
std::fs::create_dir_all(&path).expect("temp dir should be created");
Self(path)
}
fn game_root(&self) -> PathBuf {
self.0.join("game")
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn write_file(path: &Path, bytes: &[u8]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("parent dir should be created");
}
std::fs::write(path, bytes).expect("file should be written");
}
fn successful_unpacker() -> Arc<dyn Unpacker> {
Arc::new(FakeUnpacker::default())
}
#[tokio::test]
async fn install_success_promotes_staging_and_clears_intent() {
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");
install(&root, "game", successful_unpacker())
.await
.expect("install should succeed");
assert!(root.join("local").join("payload.txt").is_file());
assert!(!root.join(".local.installing").exists());
let intent = read_intent(&root, "game").await;
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();
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");
let err = update(&root, "game", Arc::new(FakeUnpacker::failing()))
.await
.expect_err("update should fail");
assert!(err.to_string().contains("forced unpack failure"));
assert!(root.join("local").join("old.txt").is_file());
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 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();
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("payload.txt"), b"installed");
uninstall(&root, "game")
.await
.expect("uninstall should succeed");
assert!(!root.join("local").exists());
assert!(root.join("game.eti").is_file());
assert!(root.join("version.ini").is_file());
}
#[tokio::test]
async fn recovery_restores_backup_for_interrupted_update() {
let temp = TempDir::new();
let root = temp.game_root();
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join(".local.backup").join("old.txt"), b"old");
write_file(&root.join(".local.installing").join("new.txt"), b"new");
write_file(&root.join(".local.backup").join(OWNED_MARKER), b"");
write_file(&root.join(".local.installing").join(OWNED_MARKER), b"");
write_intent(
&root,
&InstallIntent::new(
"game",
InstallIntentState::Updating,
Some("20250101".into()),
),
)
.await
.expect("intent should be written");
recover_game_root(&root, "game")
.await
.expect("recovery should succeed");
assert!(root.join("local").join("old.txt").is_file());
assert!(!root.join(".local.installing").exists());
assert!(!root.join(".local.backup").exists());
assert_eq!(
read_intent(&root, "game").await.state,
InstallIntentState::None
);
}
#[tokio::test]
async fn none_recovery_leaves_markerless_reserved_dirs_untouched() {
let temp = TempDir::new();
let root = temp.game_root();
write_file(&root.join(".local.backup").join("user.txt"), b"user");
recover_game_root(&root, "game")
.await
.expect("recovery should succeed");
assert!(root.join(".local.backup").join("user.txt").is_file());
}
#[tokio::test]
async fn download_recovery_sweeps_reserved_version_files() {
let temp = TempDir::new();
let root = temp.game_root();
write_file(&root.join(VERSION_TMP_FILE), b"tmp");
write_file(&root.join(VERSION_DISCARDED_FILE), b"old");
recover_game_root(&root, "game")
.await
.expect("recovery should succeed");
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());
}
}