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:
@@ -13,7 +13,7 @@ It is designed to run headless – other crates (most notably
|
||||
of the peer crate's platform layer, and the catalog set gates which local game
|
||||
roots are announced or served.
|
||||
- `PeerCommand` represents the small control surface exposed to the UI layer:
|
||||
`ListGames`, `GetGame`, `DownloadGameFiles`, `InstallGame`, `UpdateGame`,
|
||||
`ListGames`, `GetGame`, `DownloadGameFiles`, `InstallGame`,
|
||||
`UninstallGame`, and `SetGameDir`.
|
||||
- `PeerEvent` enumerates everything the peer runtime reports back to the UI:
|
||||
library snapshots, download/install/uninstall lifecycle updates, runtime
|
||||
|
||||
@@ -107,7 +107,9 @@ fn ensure_download_not_cancelled(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn partition_download_descriptions(
|
||||
/// Extracts the root `version.ini` descriptor while keeping every descriptor in
|
||||
/// the transfer list. The chunk writer diverts the sentinel bytes into memory.
|
||||
fn extract_version_descriptor(
|
||||
game_id: &str,
|
||||
game_file_descs: Vec<GameFileDescription>,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
@@ -740,7 +742,7 @@ pub async fn download_game_files(
|
||||
}
|
||||
|
||||
let (version_desc, transfer_descs) =
|
||||
partition_download_descriptions(game_id, game_file_descs, &tx_notify_ui)?;
|
||||
extract_version_descriptor(game_id, game_file_descs, &tx_notify_ui)?;
|
||||
let version_buffer = match VersionIniBuffer::new(&version_desc) {
|
||||
Ok(buffer) => Arc::new(buffer),
|
||||
Err(err) => {
|
||||
@@ -1111,10 +1113,57 @@ mod tests {
|
||||
assert!(!game_root.join(".version.ini.discarded").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn begin_version_ini_transaction_parks_existing_sentinel() {
|
||||
let temp = TempDir::new();
|
||||
let game_root = temp.path().join("game");
|
||||
tokio::fs::create_dir_all(&game_root)
|
||||
.await
|
||||
.expect("game root should be created");
|
||||
tokio::fs::write(game_root.join("version.ini"), b"20240101")
|
||||
.await
|
||||
.expect("version sentinel should be written");
|
||||
tokio::fs::write(game_root.join(".version.ini.tmp"), b"partial")
|
||||
.await
|
||||
.expect("tmp sentinel should be written");
|
||||
|
||||
begin_version_ini_transaction(&game_root)
|
||||
.await
|
||||
.expect("transaction should begin");
|
||||
|
||||
assert!(!game_root.join("version.ini").exists());
|
||||
assert!(!game_root.join(".version.ini.tmp").exists());
|
||||
assert_eq!(
|
||||
std::fs::read(game_root.join(".version.ini.discarded"))
|
||||
.expect("discarded sentinel should exist"),
|
||||
b"20240101"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rollback_version_ini_transaction_sweeps_transients() {
|
||||
let temp = TempDir::new();
|
||||
let game_root = temp.path().join("game");
|
||||
tokio::fs::create_dir_all(&game_root)
|
||||
.await
|
||||
.expect("game root should be created");
|
||||
tokio::fs::write(game_root.join(".version.ini.tmp"), b"partial")
|
||||
.await
|
||||
.expect("tmp sentinel should be written");
|
||||
tokio::fs::write(game_root.join(".version.ini.discarded"), b"old")
|
||||
.await
|
||||
.expect("discarded sentinel should be written");
|
||||
|
||||
rollback_version_ini_transaction(&game_root).await;
|
||||
|
||||
assert!(!game_root.join(".version.ini.tmp").exists());
|
||||
assert!(!game_root.join(".version.ini.discarded").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partition_requires_exactly_one_root_version_ini() {
|
||||
fn version_descriptor_extraction_keeps_nested_decoy_in_transfer_list() {
|
||||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let duplicate = vec![
|
||||
let nested_decoy = vec![
|
||||
GameFileDescription {
|
||||
game_id: "game".to_string(),
|
||||
relative_path: "game/version.ini".to_string(),
|
||||
@@ -1129,19 +1178,27 @@ mod tests {
|
||||
},
|
||||
];
|
||||
|
||||
let (version, transfer) = partition_download_descriptions("game", duplicate, &tx)
|
||||
.expect("only one root sentinel");
|
||||
let (version, transfer) =
|
||||
extract_version_descriptor("game", nested_decoy, &tx).expect("only one root sentinel");
|
||||
assert_eq!(version.relative_path, "game/version.ini");
|
||||
assert_eq!(transfer.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_descriptor_extraction_requires_a_root_version_ini() {
|
||||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let missing = vec![GameFileDescription {
|
||||
game_id: "game".to_string(),
|
||||
relative_path: "game/archive.eti".to_string(),
|
||||
is_dir: false,
|
||||
size: 1,
|
||||
}];
|
||||
assert!(partition_download_descriptions("game", missing, &tx).is_err());
|
||||
assert!(extract_version_descriptor("game", missing, &tx).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_descriptor_extraction_rejects_duplicate_root_version_ini() {
|
||||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let multiple = vec![
|
||||
GameFileDescription {
|
||||
game_id: "game".to_string(),
|
||||
@@ -1156,6 +1213,6 @@ mod tests {
|
||||
size: 8,
|
||||
},
|
||||
];
|
||||
assert!(partition_download_descriptions("game", multiple, &tx).is_err());
|
||||
assert!(extract_version_descriptor("game", multiple, &tx).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
//! Command handlers for peer commands.
|
||||
|
||||
use std::{collections::hash_map::Entry, net::SocketAddr, path::PathBuf, sync::Arc};
|
||||
use std::{
|
||||
collections::{HashSet, hash_map::Entry},
|
||||
net::SocketAddr,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use lanspread_db::db::{GameDB, GameFileDescription};
|
||||
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
||||
@@ -19,6 +24,7 @@ use crate::{
|
||||
get_game_file_descriptions,
|
||||
local_dir_is_directory,
|
||||
local_download_available,
|
||||
rescan_local_game,
|
||||
scan_local_library,
|
||||
version_ini_is_regular_file,
|
||||
},
|
||||
@@ -211,12 +217,7 @@ pub async fn handle_download_game_files_command(
|
||||
{
|
||||
log::error!("Failed to send DownloadGameFilesFinished event: {e}");
|
||||
}
|
||||
spawn_install_operation(
|
||||
ctx,
|
||||
tx_notify_ui,
|
||||
id.clone(),
|
||||
RequestedInstallOperation::Auto,
|
||||
);
|
||||
spawn_install_operation(ctx, tx_notify_ui, id.clone());
|
||||
} else {
|
||||
log::error!("No trusted peers available after majority validation for game {id}");
|
||||
}
|
||||
@@ -267,13 +268,7 @@ pub async fn handle_download_game_files_command(
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
run_install_operation(
|
||||
&ctx_clone,
|
||||
&tx_notify_ui_clone,
|
||||
download_id,
|
||||
RequestedInstallOperation::Auto,
|
||||
)
|
||||
.await;
|
||||
run_install_operation(&ctx_clone, &tx_notify_ui_clone, download_id).await;
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Download failed for {download_id}: {e}");
|
||||
@@ -288,16 +283,7 @@ pub async fn handle_install_game_command(
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
) {
|
||||
spawn_install_operation(ctx, tx_notify_ui, id, RequestedInstallOperation::Install);
|
||||
}
|
||||
|
||||
/// Handles the `UpdateGame` command.
|
||||
pub async fn handle_update_game_command(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
) {
|
||||
spawn_install_operation(ctx, tx_notify_ui, id, RequestedInstallOperation::Update);
|
||||
spawn_install_operation(ctx, tx_notify_ui, id);
|
||||
}
|
||||
|
||||
/// Handles the `UninstallGame` command.
|
||||
@@ -313,32 +299,15 @@ pub async fn handle_uninstall_game_command(
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum RequestedInstallOperation {
|
||||
Auto,
|
||||
Install,
|
||||
Update,
|
||||
}
|
||||
|
||||
fn spawn_install_operation(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
requested: RequestedInstallOperation,
|
||||
) {
|
||||
fn spawn_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
|
||||
let ctx = ctx.clone();
|
||||
let tx_notify_ui = tx_notify_ui.clone();
|
||||
ctx.task_tracker.clone().spawn(async move {
|
||||
run_install_operation(&ctx, &tx_notify_ui, id, requested).await;
|
||||
run_install_operation(&ctx, &tx_notify_ui, id).await;
|
||||
});
|
||||
}
|
||||
|
||||
async fn run_install_operation(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
requested: RequestedInstallOperation,
|
||||
) {
|
||||
async fn run_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
|
||||
if !catalog_contains(ctx, &id).await {
|
||||
log::warn!("Ignoring install command for non-catalog game {id}");
|
||||
return;
|
||||
@@ -352,14 +321,10 @@ async fn run_install_operation(
|
||||
}
|
||||
|
||||
let local_present = local_dir_is_directory(&game_root).await;
|
||||
let operation = match requested {
|
||||
RequestedInstallOperation::Auto | RequestedInstallOperation::Install if local_present => {
|
||||
InstallOperation::Updating
|
||||
}
|
||||
RequestedInstallOperation::Auto | RequestedInstallOperation::Install => {
|
||||
InstallOperation::Installing
|
||||
}
|
||||
RequestedInstallOperation::Update => InstallOperation::Updating,
|
||||
let operation = if local_present {
|
||||
InstallOperation::Updating
|
||||
} else {
|
||||
InstallOperation::Installing
|
||||
};
|
||||
let operation_kind = match operation {
|
||||
InstallOperation::Installing => OperationKind::Installing,
|
||||
@@ -371,20 +336,24 @@ async fn run_install_operation(
|
||||
return;
|
||||
}
|
||||
|
||||
let _operation_guard = OperationGuard::new(id.clone(), ctx.active_operations.clone());
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
PeerEvent::InstallGameBegin {
|
||||
id: id.clone(),
|
||||
operation,
|
||||
},
|
||||
);
|
||||
let result = {
|
||||
let _operation_guard = OperationGuard::new(id.clone(), ctx.active_operations.clone());
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
PeerEvent::InstallGameBegin {
|
||||
id: id.clone(),
|
||||
operation,
|
||||
},
|
||||
);
|
||||
|
||||
let result = match operation {
|
||||
InstallOperation::Installing => {
|
||||
install::install(&game_root, &id, ctx.unpacker.clone()).await
|
||||
match operation {
|
||||
InstallOperation::Installing => {
|
||||
install::install(&game_root, &id, ctx.unpacker.clone()).await
|
||||
}
|
||||
InstallOperation::Updating => {
|
||||
install::update(&game_root, &id, ctx.unpacker.clone()).await
|
||||
}
|
||||
}
|
||||
InstallOperation::Updating => install::update(&game_root, &id, ctx.unpacker.clone()).await,
|
||||
};
|
||||
|
||||
match result {
|
||||
@@ -393,14 +362,17 @@ async fn run_install_operation(
|
||||
tx_notify_ui,
|
||||
PeerEvent::InstallGameFinished { id: id.clone() },
|
||||
);
|
||||
if let Err(err) = load_local_library(ctx, tx_notify_ui).await {
|
||||
if let Err(err) = refresh_local_game(ctx, tx_notify_ui, &id).await {
|
||||
log::error!("Failed to refresh local library after install: {err}");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Install operation failed for {id}: {err}");
|
||||
events::send(tx_notify_ui, PeerEvent::InstallGameFailed { id });
|
||||
if let Err(refresh_err) = load_local_library(ctx, tx_notify_ui).await {
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
PeerEvent::InstallGameFailed { id: id.clone() },
|
||||
);
|
||||
if let Err(refresh_err) = refresh_local_game(ctx, tx_notify_ui, &id).await {
|
||||
log::error!("Failed to refresh local library after install failure: {refresh_err}");
|
||||
}
|
||||
}
|
||||
@@ -418,14 +390,18 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerE
|
||||
return;
|
||||
}
|
||||
|
||||
let _operation_guard = OperationGuard::new(id.clone(), ctx.active_operations.clone());
|
||||
let game_root = { ctx.game_dir.read().await.join(&id) };
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
PeerEvent::UninstallGameBegin { id: id.clone() },
|
||||
);
|
||||
let result = {
|
||||
let _operation_guard = OperationGuard::new(id.clone(), ctx.active_operations.clone());
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
PeerEvent::UninstallGameBegin { id: id.clone() },
|
||||
);
|
||||
|
||||
match install::uninstall(&game_root, &id).await {
|
||||
install::uninstall(&game_root, &id).await
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
@@ -441,7 +417,7 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerE
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = load_local_library(ctx, tx_notify_ui).await {
|
||||
if let Err(err) = refresh_local_game(ctx, tx_notify_ui, &id).await {
|
||||
log::error!("Failed to refresh local library after uninstall: {err}");
|
||||
}
|
||||
}
|
||||
@@ -467,6 +443,32 @@ pub async fn handle_set_game_dir_command(
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
game_dir: PathBuf,
|
||||
) {
|
||||
let current_game_dir = ctx.game_dir.read().await.clone();
|
||||
if current_game_dir == game_dir {
|
||||
log::info!(
|
||||
"Game directory {} unchanged; refreshing without recovery",
|
||||
game_dir.display()
|
||||
);
|
||||
let tx_notify_ui = tx_notify_ui.clone();
|
||||
let ctx_clone = ctx.clone();
|
||||
ctx.task_tracker.spawn(async move {
|
||||
if let Err(err) = refresh_local_library(&ctx_clone, &tx_notify_ui).await {
|
||||
log::error!("Failed to refresh local game database: {err}");
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let active_ids = active_operation_ids(ctx).await;
|
||||
if !active_ids.is_empty() {
|
||||
log::warn!(
|
||||
"Rejecting game directory change to {} while operations are active for: {}",
|
||||
game_dir.display(),
|
||||
active_ids.into_iter().collect::<Vec<_>>().join(", ")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
*ctx.game_dir.write().await = game_dir.clone();
|
||||
log::info!("Game directory set to: {}", game_dir.display());
|
||||
|
||||
@@ -489,13 +491,46 @@ pub async fn load_local_library(
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
) -> eyre::Result<()> {
|
||||
let game_dir = { ctx.game_dir.read().await.clone() };
|
||||
install::recover_on_startup(&game_dir).await?;
|
||||
let active_ids = active_operation_ids(ctx).await;
|
||||
install::recover_on_startup(&game_dir, &active_ids).await?;
|
||||
scan_and_announce_local_library(ctx, tx_notify_ui, &game_dir).await
|
||||
}
|
||||
|
||||
async fn refresh_local_library(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
) -> eyre::Result<()> {
|
||||
let game_dir = { ctx.game_dir.read().await.clone() };
|
||||
scan_and_announce_local_library(ctx, tx_notify_ui, &game_dir).await
|
||||
}
|
||||
|
||||
async fn scan_and_announce_local_library(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
game_dir: &Path,
|
||||
) -> eyre::Result<()> {
|
||||
let catalog = ctx.catalog.read().await.clone();
|
||||
let scan = scan_local_library(&game_dir, &catalog).await?;
|
||||
let scan = scan_local_library(game_dir, &catalog).await?;
|
||||
update_and_announce_games(ctx, tx_notify_ui, scan).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn refresh_local_game(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: &str,
|
||||
) -> eyre::Result<()> {
|
||||
let game_dir = { ctx.game_dir.read().await.clone() };
|
||||
let catalog = ctx.catalog.read().await.clone();
|
||||
let scan = rescan_local_game(&game_dir, &catalog, id).await?;
|
||||
update_and_announce_games(ctx, tx_notify_ui, scan).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn active_operation_ids(ctx: &Ctx) -> HashSet<String> {
|
||||
ctx.active_operations.read().await.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Handles the `GetPeerCount` command.
|
||||
pub async fn handle_get_peer_count_command(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>) {
|
||||
log::info!("GetPeerCount command received");
|
||||
@@ -589,3 +624,225 @@ pub async fn update_and_announce_games(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
|
||||
use super::*;
|
||||
use crate::{UnpackFuture, Unpacker};
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new(prefix: &str) -> Self {
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!(
|
||||
"{prefix}-{}-{}",
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&path).expect("temp dir should be created");
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeUnpacker;
|
||||
|
||||
impl Unpacker for FakeUnpacker {
|
||||
fn unpack<'a>(&'a self, _archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
|
||||
Box::pin(async move {
|
||||
tokio::fs::write(dest.join("payload.txt"), b"installed").await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 test_ctx(game_dir: PathBuf) -> Ctx {
|
||||
Ctx::new(
|
||||
Arc::new(RwLock::new(PeerGameDB::new())),
|
||||
"peer".to_string(),
|
||||
game_dir,
|
||||
Arc::new(FakeUnpacker),
|
||||
CancellationToken::new(),
|
||||
TaskTracker::new(),
|
||||
Arc::new(RwLock::new(HashSet::from(["game".to_string()]))),
|
||||
)
|
||||
}
|
||||
|
||||
async fn recv_event(rx: &mut mpsc::UnboundedReceiver<PeerEvent>) -> PeerEvent {
|
||||
tokio::time::timeout(Duration::from_secs(1), rx.recv())
|
||||
.await
|
||||
.expect("event should arrive")
|
||||
.expect("event channel should remain open")
|
||||
}
|
||||
|
||||
fn assert_local_update(event: PeerEvent, installed: bool, downloaded: bool) {
|
||||
let PeerEvent::LocalGamesUpdated(games) = event else {
|
||||
panic!("expected LocalGamesUpdated");
|
||||
};
|
||||
let game = games
|
||||
.iter()
|
||||
.find(|game| game.id == "game")
|
||||
.expect("game should be announced");
|
||||
assert_eq!(game.installed, installed);
|
||||
assert_eq!(game.downloaded, downloaded);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_refreshes_settled_state_after_guard_release() {
|
||||
let temp = TempDir::new("lanspread-handler-install");
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
write_file(&root.join("game.eti"), b"archive");
|
||||
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
||||
|
||||
match recv_event(&mut rx).await {
|
||||
PeerEvent::InstallGameBegin { id, operation } => {
|
||||
assert_eq!(id, "game");
|
||||
assert_eq!(operation, InstallOperation::Installing);
|
||||
}
|
||||
_ => panic!("expected InstallGameBegin"),
|
||||
}
|
||||
assert!(matches!(
|
||||
recv_event(&mut rx).await,
|
||||
PeerEvent::InstallGameFinished { id } if id == "game"
|
||||
));
|
||||
assert!(ctx.active_operations.read().await.is_empty());
|
||||
assert_local_update(recv_event(&mut rx).await, true, true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_refreshes_settled_state_after_guard_release() {
|
||||
let temp = TempDir::new("lanspread-handler-update");
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
write_file(&root.join("game.eti"), b"archive");
|
||||
write_file(&root.join("local").join("old.txt"), b"old");
|
||||
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
||||
|
||||
match recv_event(&mut rx).await {
|
||||
PeerEvent::InstallGameBegin { id, operation } => {
|
||||
assert_eq!(id, "game");
|
||||
assert_eq!(operation, InstallOperation::Updating);
|
||||
}
|
||||
_ => panic!("expected InstallGameBegin"),
|
||||
}
|
||||
assert!(matches!(
|
||||
recv_event(&mut rx).await,
|
||||
PeerEvent::InstallGameFinished { id } if id == "game"
|
||||
));
|
||||
assert!(ctx.active_operations.read().await.is_empty());
|
||||
assert_local_update(recv_event(&mut rx).await, true, true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uninstall_refreshes_settled_state_after_guard_release() {
|
||||
let temp = TempDir::new("lanspread-handler-uninstall");
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
write_file(&root.join("game.eti"), b"archive");
|
||||
write_file(&root.join("local").join("old.txt"), b"old");
|
||||
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
run_uninstall_operation(&ctx, &tx, "game".to_string()).await;
|
||||
|
||||
assert!(matches!(
|
||||
recv_event(&mut rx).await,
|
||||
PeerEvent::UninstallGameBegin { id } if id == "game"
|
||||
));
|
||||
assert!(matches!(
|
||||
recv_event(&mut rx).await,
|
||||
PeerEvent::UninstallGameFinished { id } if id == "game"
|
||||
));
|
||||
assert!(ctx.active_operations.read().await.is_empty());
|
||||
assert_local_update(recv_event(&mut rx).await, false, true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn path_changing_set_game_dir_is_rejected_while_operations_are_active() {
|
||||
let current = TempDir::new("lanspread-handler-current-dir");
|
||||
let next = TempDir::new("lanspread-handler-next-dir");
|
||||
let ctx = test_ctx(current.path().to_path_buf());
|
||||
ctx.active_operations
|
||||
.write()
|
||||
.await
|
||||
.insert("game".to_string(), OperationKind::Downloading);
|
||||
let (tx, _rx) = mpsc::unbounded_channel();
|
||||
|
||||
handle_set_game_dir_command(&ctx, &tx, next.path().to_path_buf()).await;
|
||||
|
||||
assert_eq!(*ctx.game_dir.read().await, current.path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_path_set_game_dir_refreshes_without_recovery() {
|
||||
let temp = TempDir::new("lanspread-handler-same-dir");
|
||||
write_file(&temp.game_root().join(".version.ini.tmp"), b"tmp");
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, _rx) = mpsc::unbounded_channel();
|
||||
|
||||
handle_set_game_dir_command(&ctx, &tx, temp.path().to_path_buf()).await;
|
||||
ctx.task_tracker.close();
|
||||
ctx.task_tracker.wait().await;
|
||||
|
||||
assert!(temp.game_root().join(".version.ini.tmp").is_file());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn path_changing_set_game_dir_runs_recovery() {
|
||||
let current = TempDir::new("lanspread-handler-old-dir");
|
||||
let next = TempDir::new("lanspread-handler-new-dir");
|
||||
write_file(&next.game_root().join(".version.ini.tmp"), b"tmp");
|
||||
let ctx = test_ctx(current.path().to_path_buf());
|
||||
let (tx, _rx) = mpsc::unbounded_channel();
|
||||
|
||||
handle_set_game_dir_command(&ctx, &tx, next.path().to_path_buf()).await;
|
||||
ctx.task_tracker.close();
|
||||
ctx.task_tracker.wait().await;
|
||||
|
||||
assert!(!next.game_root().join(".version.ini.tmp").exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,6 @@ use crate::{
|
||||
handle_list_games_command,
|
||||
handle_set_game_dir_command,
|
||||
handle_uninstall_game_command,
|
||||
handle_update_game_command,
|
||||
load_local_library,
|
||||
},
|
||||
};
|
||||
@@ -159,8 +158,6 @@ pub enum PeerCommand {
|
||||
},
|
||||
/// Install already-downloaded archives into `local/`.
|
||||
InstallGame { id: String },
|
||||
/// Update an installed game from already-downloaded archives.
|
||||
UpdateGame { id: String },
|
||||
/// Remove only the `local/` install for a game.
|
||||
UninstallGame { id: String },
|
||||
/// Set the local game directory.
|
||||
@@ -291,9 +288,6 @@ async fn handle_peer_commands(
|
||||
PeerCommand::InstallGame { id } => {
|
||||
handle_install_game_command(ctx, tx_notify_ui, id).await;
|
||||
}
|
||||
PeerCommand::UpdateGame { id } => {
|
||||
handle_update_game_command(ctx, tx_notify_ui, id).await;
|
||||
}
|
||||
PeerCommand::UninstallGame { id } => {
|
||||
handle_uninstall_game_command(ctx, tx_notify_ui, id).await;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,14 @@ use std::{
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use lanspread_db::db::{Game, GameDB, GameFileDescription};
|
||||
use lanspread_db::db::{
|
||||
AVAILABILITY_DOWNLOADING,
|
||||
AVAILABILITY_LOCAL_ONLY,
|
||||
AVAILABILITY_READY,
|
||||
Game,
|
||||
GameDB,
|
||||
GameFileDescription,
|
||||
};
|
||||
use lanspread_proto::{Availability, GameSummary};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -380,12 +387,21 @@ pub(crate) fn game_from_summary(summary: &GameSummary) -> Game {
|
||||
size: summary.size,
|
||||
downloaded: summary.downloaded,
|
||||
installed: summary.installed,
|
||||
availability: availability_label(&summary.availability).to_string(),
|
||||
eti_game_version: summary.eti_version.clone(),
|
||||
local_version: summary.eti_version.clone(),
|
||||
peer_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn availability_label(availability: &Availability) -> &'static str {
|
||||
match availability {
|
||||
Availability::Ready => AVAILABILITY_READY,
|
||||
Availability::Downloading => AVAILABILITY_DOWNLOADING,
|
||||
Availability::LocalOnly => AVAILABILITY_LOCAL_ONLY,
|
||||
}
|
||||
}
|
||||
|
||||
struct IndexUpdate {
|
||||
summary: Option<GameSummary>,
|
||||
changed: bool,
|
||||
|
||||
@@ -7,10 +7,10 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use lanspread_db::db::{Game, GameFileDescription};
|
||||
use lanspread_db::db::{AVAILABILITY_READY, Game, GameFileDescription};
|
||||
use lanspread_proto::{Availability, GameSummary, LibraryDelta, LibrarySnapshot};
|
||||
|
||||
use crate::library::compute_library_digest;
|
||||
use crate::{library::compute_library_digest, local_games::availability_label};
|
||||
pub type PeerId = String;
|
||||
|
||||
/// Information about a discovered peer.
|
||||
@@ -293,6 +293,10 @@ impl PeerGameDB {
|
||||
}
|
||||
if game_is_ready(game) {
|
||||
existing.downloaded = true;
|
||||
existing.availability = AVAILABILITY_READY.to_string();
|
||||
} else if !existing.downloaded {
|
||||
existing.availability =
|
||||
availability_label(&game.availability).to_string();
|
||||
}
|
||||
if game.installed {
|
||||
existing.installed = true;
|
||||
@@ -744,6 +748,7 @@ fn summary_to_game(summary: &GameSummary) -> Game {
|
||||
size: summary.size,
|
||||
downloaded: summary.downloaded,
|
||||
installed: summary.installed,
|
||||
availability: availability_label(&summary.availability).to_string(),
|
||||
eti_game_version,
|
||||
local_version: None,
|
||||
peer_count: 0,
|
||||
@@ -754,6 +759,8 @@ fn summary_to_game(summary: &GameSummary) -> Game {
|
||||
mod tests {
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use lanspread_db::db::AVAILABILITY_LOCAL_ONLY;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn addr(port: u16) -> SocketAddr {
|
||||
@@ -817,6 +824,7 @@ mod tests {
|
||||
assert_eq!(games.len(), 1);
|
||||
assert_eq!(games[0].peer_count, 0);
|
||||
assert!(!games[0].downloaded);
|
||||
assert_eq!(games[0].availability, AVAILABILITY_LOCAL_ONLY);
|
||||
assert_eq!(games[0].eti_game_version, None);
|
||||
|
||||
assert!(db.peers_with_game("game").is_empty());
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
use std::{collections::HashMap, net::SocketAddr, sync::Arc};
|
||||
|
||||
use lanspread_db::db::Game;
|
||||
use lanspread_db::db::{
|
||||
AVAILABILITY_DOWNLOADING,
|
||||
AVAILABILITY_LOCAL_ONLY,
|
||||
AVAILABILITY_READY,
|
||||
Game,
|
||||
};
|
||||
use lanspread_proto::{Availability, GameSummary};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
@@ -26,10 +31,12 @@ pub async fn ensure_peer_id_for_addr(
|
||||
}
|
||||
|
||||
pub fn summary_from_game(game: &Game) -> GameSummary {
|
||||
let availability = if game.downloaded {
|
||||
Availability::Ready
|
||||
} else {
|
||||
Availability::LocalOnly
|
||||
let availability = match game.availability.as_str() {
|
||||
AVAILABILITY_READY => Availability::Ready,
|
||||
AVAILABILITY_DOWNLOADING => Availability::Downloading,
|
||||
AVAILABILITY_LOCAL_ONLY => Availability::LocalOnly,
|
||||
_ if game.downloaded => Availability::Ready,
|
||||
_ => Availability::LocalOnly,
|
||||
};
|
||||
|
||||
GameSummary {
|
||||
|
||||
Reference in New Issue
Block a user