Files
lanspread/crates/lanspread-peer/src/handlers.rs
T
ddidderr 894eb5af6a test(peer): consolidate temp dir helper
Move the repeated test TempDir implementations into a single peer
test_support module. The shared helper keeps the existing automatic cleanup
behavior and uses an atomic suffix plus timestamp so parallel tests do not
collide on the same path.

This is intentionally limited to test hygiene. It does not change the
availability model, split download.rs, or touch production scan/install
behavior beyond importing the shared helper from test modules.

Test Plan:
- git diff --check
- just fmt
- just clippy
- just test

Follow-up-Plan: FOLLOW_UP_2.md
2026-05-16 09:21:43 +02:00

927 lines
31 KiB
Rust

//! Command handlers for peer commands.
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};
use crate::{
ActiveOperation,
ActiveOperationKind,
InstallOperation,
PeerEvent,
context::{Ctx, OperationGuard, OperationKind},
download::download_game_files,
events,
identity::FEATURE_LIBRARY_DELTA,
install,
local_games::{
LocalLibraryScan,
game_from_summary,
get_game_file_descriptions,
local_dir_is_directory,
local_download_available,
rescan_local_game,
scan_local_library,
version_ini_is_regular_file,
},
network::{announce_games_to_peer, request_game_details_from_peer, send_library_delta},
peer_db::PeerGameDB,
remote_peer::ensure_peer_id_for_addr,
};
// =============================================================================
// Command handlers
// =============================================================================
/// Handles the `ListGames` command.
pub async fn handle_list_games_command(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>) {
log::info!("ListGames command received");
events::emit_peer_game_list(&ctx.peer_game_db, tx_notify_ui).await;
}
/// Tries to serve a game from local files.
async fn try_serve_local_game(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: &str,
) -> bool {
let game_dir = { ctx.game_dir.read().await.clone() };
let active_operations = ctx.active_operations.read().await;
let catalog = ctx.catalog.read().await;
if !local_download_available(&game_dir, id, &active_operations, &catalog).await {
return false;
}
drop(active_operations);
drop(catalog);
match get_game_file_descriptions(id, &game_dir).await {
Ok(file_descriptions) => {
log::info!("Serving game {id} from local files");
if let Err(e) = tx_notify_ui.send(PeerEvent::GotGameFiles {
id: id.to_string(),
file_descriptions,
}) {
log::error!("Failed to send GotGameFiles event: {e}");
}
true
}
Err(e) => {
log::error!("Failed to enumerate local file descriptions for {id}: {e}");
false
}
}
}
/// Handles the `GetGame` command.
pub async fn handle_get_game_command(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String,
) {
if try_serve_local_game(ctx, tx_notify_ui, &id).await {
return;
}
log::info!("Requesting game from peers: {id}");
let peers = { ctx.peer_game_db.read().await.peers_with_game(&id) };
if peers.is_empty() {
log::warn!("No peers have game {id}");
if let Err(e) = tx_notify_ui.send(PeerEvent::NoPeersHaveGame { id: id.clone() }) {
log::error!("Failed to send NoPeersHaveGame event: {e}");
}
return;
}
let peer_game_db = ctx.peer_game_db.clone();
let tx_notify_ui = tx_notify_ui.clone();
ctx.task_tracker.spawn(async move {
let mut fetched_any = false;
for peer_addr in peers {
match request_game_details_and_update(peer_addr, &id, peer_game_db.clone()).await {
Ok(_) => {
log::info!("Fetched game file list for {id} from peer {peer_addr}");
fetched_any = true;
}
Err(e) => {
log::error!("Failed to fetch game files for {id} from {peer_addr}: {e}");
}
}
}
if fetched_any {
let aggregated_files = { peer_game_db.read().await.aggregated_game_files(&id) };
if let Err(e) = tx_notify_ui.send(PeerEvent::GotGameFiles {
id: id.clone(),
file_descriptions: aggregated_files,
}) {
log::error!("Failed to send GotGameFiles event: {e}");
}
} else {
log::warn!("Failed to retrieve game files for {id} from any peer");
}
});
}
/// Requests game details from a peer and updates the peer game database.
async fn request_game_details_and_update(
peer_addr: SocketAddr,
game_id: &str,
peer_game_db: Arc<RwLock<PeerGameDB>>,
) -> eyre::Result<Vec<GameFileDescription>> {
let (file_descriptions, _) = request_game_details_from_peer(peer_addr, game_id).await?;
let peer_id = ensure_peer_id_for_addr(&peer_game_db, peer_addr).await;
{
let mut db = peer_game_db.write().await;
db.update_peer_game_files(&peer_id, game_id, file_descriptions.clone());
}
Ok(file_descriptions)
}
/// Handles the `DownloadGameFiles` command.
#[allow(clippy::too_many_lines)]
pub async fn handle_download_game_files_command(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String,
file_descriptions: Vec<GameFileDescription>,
) {
log::info!("Got PeerCommand::DownloadGameFiles");
let games_folder = { ctx.game_dir.read().await.clone() };
// Use majority validation to get trusted file descriptions and peer whitelist
let (validated_descriptions, peer_whitelist, file_peer_map) = {
match ctx
.peer_game_db
.read()
.await
.validate_file_sizes_majority(&id)
{
Ok((files, peers, file_peer_map)) => {
log::info!(
"Majority validation: {} validated files, {} trusted peers for game {id}",
files.len(),
peers.len()
);
(files, peers, file_peer_map)
}
Err(e) => {
log::error!("File size majority validation failed for {id}: {e}");
if let Err(send_err) =
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id: id.clone() })
{
log::error!("Failed to send DownloadGameFilesFailed event: {send_err}");
}
return;
}
}
};
let resolved_descriptions = if file_descriptions.is_empty() {
validated_descriptions
} else {
// If user provided specific descriptions, still validate them against majority
// but keep user's selection (they might want specific files)
file_descriptions
};
if resolved_descriptions.is_empty() {
log::error!(
"No validated file descriptions available to download game {id}; request metadata first"
);
return;
}
let local_dl_available = {
let active_operations = ctx.active_operations.read().await;
let catalog = ctx.catalog.read().await;
local_download_available(&games_folder, &id, &active_operations, &catalog).await
};
if peer_whitelist.is_empty() {
if local_dl_available {
log::info!("Using locally downloaded files for game {id}; skipping peer transfer");
if let Err(e) = tx_notify_ui.send(PeerEvent::DownloadGameFilesBegin { id: id.clone() })
{
log::error!("Failed to send DownloadGameFilesBegin event: {e}");
}
if let Err(e) =
tx_notify_ui.send(PeerEvent::DownloadGameFilesFinished { id: id.clone() })
{
log::error!("Failed to send DownloadGameFilesFinished event: {e}");
}
spawn_install_operation(ctx, tx_notify_ui, id.clone());
} else {
log::error!("No trusted peers available after majority validation for game {id}");
}
return;
}
{
let mut in_progress = ctx.active_operations.write().await;
match in_progress.entry(id.clone()) {
Entry::Vacant(entry) => {
entry.insert(OperationKind::Downloading);
}
Entry::Occupied(_) => {
log::warn!("Operation for {id} already in progress; ignoring new download request");
return;
}
}
}
let active_operations = ctx.active_operations.clone();
let active_downloads = ctx.active_downloads.clone();
let tx_notify_ui_clone = tx_notify_ui.clone();
let download_id = id.clone();
let cancel_token = ctx.shutdown.child_token();
let ctx_clone = ctx.clone();
ctx.active_downloads
.write()
.await
.insert(id, cancel_token.clone());
ctx.task_tracker.spawn(async move {
let result = {
let _download_state_guard =
OperationGuard::download(download_id.clone(), active_operations, active_downloads);
download_game_files(
&download_id,
resolved_descriptions,
games_folder,
peer_whitelist,
file_peer_map,
tx_notify_ui_clone.clone(),
cancel_token,
)
.await
};
match result {
Ok(()) => {
run_install_operation(&ctx_clone, &tx_notify_ui_clone, download_id).await;
}
Err(e) => {
log::error!("Download failed for {download_id}: {e}");
}
}
});
}
/// Handles the `InstallGame` command.
pub async fn handle_install_game_command(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String,
) {
spawn_install_operation(ctx, tx_notify_ui, id);
}
/// Handles the `UninstallGame` command.
pub async fn handle_uninstall_game_command(
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_uninstall_operation(&ctx, &tx_notify_ui, id).await;
});
}
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).await;
});
}
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;
}
let game_root = { ctx.game_dir.read().await.join(&id) };
if !version_ini_is_regular_file(&game_root).await {
log::warn!("Ignoring install command for {id}: version.ini sentinel is absent");
events::send(tx_notify_ui, PeerEvent::InstallGameFailed { id });
return;
}
let local_present = local_dir_is_directory(&game_root).await;
let operation = if local_present {
InstallOperation::Updating
} else {
InstallOperation::Installing
};
let operation_kind = match operation {
InstallOperation::Installing => OperationKind::Installing,
InstallOperation::Updating => OperationKind::Updating,
};
if !begin_operation(ctx, &id, operation_kind).await {
log::warn!("Operation for {id} already in progress; ignoring install command");
return;
}
let result = {
let _operation_guard = OperationGuard::new(id.clone(), ctx.active_operations.clone());
events::send(
tx_notify_ui,
PeerEvent::InstallGameBegin {
id: id.clone(),
operation,
},
);
match operation {
InstallOperation::Installing => {
install::install(&game_root, &id, ctx.unpacker.clone()).await
}
InstallOperation::Updating => {
install::update(&game_root, &id, ctx.unpacker.clone()).await
}
}
};
match result {
Ok(()) => {
events::send(
tx_notify_ui,
PeerEvent::InstallGameFinished { id: id.clone() },
);
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: 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}");
}
}
}
}
async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
if !catalog_contains(ctx, &id).await {
log::warn!("Ignoring uninstall command for non-catalog game {id}");
return;
}
if !begin_operation(ctx, &id, OperationKind::Uninstalling).await {
log::warn!("Operation for {id} already in progress; ignoring uninstall command");
return;
}
let game_root = { ctx.game_dir.read().await.join(&id) };
let result = {
let _operation_guard = OperationGuard::new(id.clone(), ctx.active_operations.clone());
events::send(
tx_notify_ui,
PeerEvent::UninstallGameBegin { id: id.clone() },
);
install::uninstall(&game_root, &id).await
};
match result {
Ok(()) => {
events::send(
tx_notify_ui,
PeerEvent::UninstallGameFinished { id: id.clone() },
);
}
Err(err) => {
log::error!("Uninstall operation failed for {id}: {err}");
events::send(
tx_notify_ui,
PeerEvent::UninstallGameFailed { id: id.clone() },
);
}
}
if let Err(err) = refresh_local_game(ctx, tx_notify_ui, &id).await {
log::error!("Failed to refresh local library after uninstall: {err}");
}
}
async fn begin_operation(ctx: &Ctx, id: &str, operation: OperationKind) -> bool {
let mut active_operations = ctx.active_operations.write().await;
match active_operations.entry(id.to_string()) {
Entry::Vacant(entry) => {
entry.insert(operation);
true
}
Entry::Occupied(_) => false,
}
}
async fn catalog_contains(ctx: &Ctx, id: &str) -> bool {
ctx.catalog.read().await.contains(id)
}
/// Handles the `SetGameDir` command.
pub async fn handle_set_game_dir_command(
ctx: &Ctx,
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());
let tx_notify_ui = tx_notify_ui.clone();
let ctx_clone = ctx.clone();
ctx.task_tracker.spawn(async move {
match load_local_library(&ctx_clone, &tx_notify_ui).await {
Ok(()) => log::info!("Local game database loaded successfully"),
Err(e) => {
log::error!("Failed to load local game database: {e}");
}
}
});
}
/// Loads the configured local library and announces the result.
pub async fn load_local_library(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
) -> eyre::Result<()> {
let game_dir = { ctx.game_dir.read().await.clone() };
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?;
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");
events::emit_peer_count(&ctx.peer_game_db, tx_notify_ui).await;
}
// =============================================================================
// Game announcement helpers
// =============================================================================
/// Updates the local game database and announces changes to peers.
pub async fn update_and_announce_games(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
scan: LocalLibraryScan,
) {
let LocalLibraryScan {
mut game_db,
mut summaries,
revision,
} = scan;
let active_operations = active_operation_snapshot(ctx).await;
if !active_operations.is_empty() {
let previous = ctx.local_library.read().await.games.clone();
for id in active_operations.iter().map(|operation| &operation.id) {
if let Some(summary) = previous.get(id) {
summaries.insert(id.clone(), summary.clone());
} else {
summaries.remove(id);
}
}
game_db = GameDB::from(summaries.values().map(game_from_summary).collect());
}
let delta = {
let mut library_guard = ctx.local_library.write().await;
library_guard.update_from_scan(summaries, revision)
};
{
let mut db_guard = ctx.local_game_db.write().await;
*db_guard = Some(game_db.clone());
}
let all_games = game_db.all_games().into_iter().cloned().collect::<Vec<_>>();
if let Err(e) = tx_notify_ui.send(PeerEvent::LocalGamesUpdated {
games: all_games.clone(),
active_operations,
}) {
log::error!("Failed to send LocalGamesUpdated event: {e}");
}
let Some(delta) = delta else {
return;
};
let peer_targets = {
let db = ctx.peer_game_db.read().await;
db.peer_identities()
.into_iter()
.map(|(peer_id, addr)| {
let features = db.peer_features(&peer_id);
(peer_id, addr, features)
})
.collect::<Vec<_>>()
};
for (_peer_id, peer_addr, features) in peer_targets {
if features
.iter()
.any(|feature| feature == FEATURE_LIBRARY_DELTA)
{
let delta = delta.clone();
ctx.task_tracker.spawn(async move {
if let Err(e) = send_library_delta(peer_addr, delta).await {
log::warn!("Failed to send library delta to {peer_addr}: {e}");
}
});
} else {
let games_clone = all_games.clone();
ctx.task_tracker.spawn(async move {
if let Err(e) = announce_games_to_peer(peer_addr, games_clone).await {
log::warn!("Failed to announce games to {peer_addr}: {e}");
}
});
}
}
}
async fn active_operation_snapshot(ctx: &Ctx) -> Vec<ActiveOperation> {
let active_operations = ctx.active_operations.read().await;
let mut snapshot = active_operations
.iter()
.map(|(id, operation)| ActiveOperation {
id: id.clone(),
operation: active_operation_kind(*operation),
})
.collect::<Vec<_>>();
snapshot.sort_by(|left, right| left.id.cmp(&right.id));
snapshot
}
fn active_operation_kind(operation: OperationKind) -> ActiveOperationKind {
match operation {
OperationKind::Downloading => ActiveOperationKind::Downloading,
OperationKind::Installing => ActiveOperationKind::Installing,
OperationKind::Updating => ActiveOperationKind::Updating,
OperationKind::Uninstalling => ActiveOperationKind::Uninstalling,
}
}
#[cfg(test)]
mod tests {
use std::{
collections::HashSet,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use tokio::sync::mpsc;
use tokio_util::{sync::CancellationToken, task::TaskTracker};
use super::*;
use crate::{UnpackFuture, Unpacker, test_support::TempDir};
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,
active_operations,
} = event
else {
panic!("expected LocalGamesUpdated");
};
assert!(
active_operations.is_empty(),
"settled local update should not report active operations"
);
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 local_games_update_reports_authoritative_active_operations() {
let temp = TempDir::new("lanspread-handler-active-snapshot");
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());
ctx.active_operations
.write()
.await
.insert("game".to_string(), OperationKind::Installing);
let (tx, mut rx) = mpsc::unbounded_channel();
let catalog = ctx.catalog.read().await.clone();
let scan = scan_local_library(temp.path(), &catalog)
.await
.expect("scan should succeed");
update_and_announce_games(&ctx, &tx, scan).await;
let PeerEvent::LocalGamesUpdated {
games,
active_operations,
} = recv_event(&mut rx).await
else {
panic!("expected LocalGamesUpdated");
};
assert!(
games.is_empty(),
"active game should keep its previous announced state"
);
assert_eq!(
active_operations,
vec![ActiveOperation {
id: "game".to_string(),
operation: ActiveOperationKind::Installing,
}]
);
}
#[tokio::test]
async fn unchanged_scan_still_reports_active_operation_snapshot() {
let temp = TempDir::new("lanspread-handler-active-unchanged");
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();
let catalog = ctx.catalog.read().await.clone();
let scan = scan_local_library(temp.path(), &catalog)
.await
.expect("first scan should succeed");
update_and_announce_games(&ctx, &tx, scan).await;
assert_local_update(recv_event(&mut rx).await, false, true);
ctx.active_operations
.write()
.await
.insert("game".to_string(), OperationKind::Updating);
let scan = scan_local_library(temp.path(), &catalog)
.await
.expect("second scan should succeed");
update_and_announce_games(&ctx, &tx, scan).await;
let PeerEvent::LocalGamesUpdated {
active_operations, ..
} = recv_event(&mut rx).await
else {
panic!("expected LocalGamesUpdated");
};
assert_eq!(
active_operations,
vec![ActiveOperation {
id: "game".to_string(),
operation: ActiveOperationKind::Updating,
}]
);
}
#[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());
}
}