Files
lanspread/crates/lanspread-peer/src/handlers.rs
T
ddidderr 373def6d44 feat(peer): prototype streamed installs
Add a streamed-install prototype that can receive archive-derived install bytes
straight into local/ without first storing the peer-owned root archive payload.
This is intended for low-disk clients that want to install a game but opt out of
becoming a downloadable peer source for that game.

The protocol gains a current-version-only StreamInstall request and framed
StreamInstallFrame responses. The peer core owns the generic transport,
transaction, path validation, size checks, CRC32 verification, and lifecycle
state. The archive-specific work is hidden behind StreamInstallProvider so the
prototype can use unrar while the final implementation can swap in a better
provider without rewriting the peer command path.

The receiver writes into .local.installing and only promotes to local/ after the
full stream verifies. It deliberately does not write the root version.ini or
archive files, so the settled local state is installed=true, downloaded=false,
and availability=LocalOnly. That preserves the existing rule that local/ is not
served to peers and makes streamed receivers non-sources by construction.

The CLI is the only caller for now. It exposes stream-install and provides the
prototype unrar implementation with unrar lt for entry metadata and unrar p for
file bytes. This is simple and good enough to prove non-solid archive streaming,
but it is not the production provider shape for solid archives because per-file
unrar p would repeatedly decompress prefixes. The Tauri app explicitly passes
stream_install_provider: None, so the GUI behavior stays unchanged until a real
product path is designed.

Document the production-readiness work in NEXT_STEPS.md. The main follow-up is
to make the provider abstraction final-ish and replace the per-file CLI unrar
provider with a one-pass archive provider, then wire a deliberate GUI low-disk
mode, retry semantics, and broader failure scenarios.

Test Plan:
- just fmt
- RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just test
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py \
  S39 S40 --build-image
- RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just clippy
- git diff --check
- git diff --cached --check

Follow-up: NEXT_STEPS.md
2026-06-07 20:32:05 +02:00

2311 lines
78 KiB
Rust

//! Command handlers for peer commands.
use std::{
collections::{HashSet, hash_map::Entry},
future::Future,
net::SocketAddr,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use lanspread_db::db::{GameDB, GameFileDescription};
use tokio::sync::{RwLock, mpsc::UnboundedSender};
use tokio_util::sync::CancellationToken;
use crate::{
InstallOperation,
PeerEvent,
context::{Ctx, OperationGuard, OperationKind},
download::download_game_files,
events,
install,
local_games::{
LocalLibraryScan,
game_from_summary,
get_game_file_descriptions,
local_dir_is_directory,
local_download_matches_catalog,
rescan_local_game,
scan_local_library,
version_ini_is_regular_file,
},
network::{request_game_details_from_peer, send_library_delta},
peer_db::PeerGameDB,
remote_peer::ensure_peer_id_for_addr,
services::{HandshakeCtx, perform_handshake_with_peer},
stream_install::receive_streamed_install,
};
// =============================================================================
// Command handlers
// =============================================================================
const OUTBOUND_TRANSFER_DRAIN_POLL_INTERVAL: Duration = Duration::from_millis(10);
const OUTBOUND_TRANSFER_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
/// 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, &ctx.catalog, 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_matches_catalog(&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(crate) async fn handle_get_game_command(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String,
source: GameDetailSource,
) {
if source.allows_local() && try_serve_local_game(ctx, tx_notify_ui, &id).await {
return;
}
log::info!("Requesting game from peers: {id}");
let expected_version = catalog_expected_version(ctx, &id).await;
let peers = {
let peer_game_db = ctx.peer_game_db.read().await;
source.select_peers(&peer_game_db, &id, expected_version.as_deref())
};
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(fetch_game_details_from_peers(
peers,
id,
expected_version,
peer_game_db,
tx_notify_ui,
|peer_addr, game_id, peer_game_db| async move {
request_game_details_and_update(peer_addr, &game_id, peer_game_db).await
},
));
}
#[derive(Clone, Copy, Debug)]
pub(crate) enum GameDetailSource {
LocalOrPeers,
LatestPeersOnly,
}
impl GameDetailSource {
fn allows_local(self) -> bool {
matches!(self, Self::LocalOrPeers)
}
fn select_peers(
self,
peer_game_db: &PeerGameDB,
id: &str,
expected_version: Option<&str>,
) -> Vec<SocketAddr> {
match self {
Self::LocalOrPeers | Self::LatestPeersOnly => {
peer_game_db.peers_with_expected_version(id, expected_version)
}
}
}
}
/// 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)
}
async fn fetch_game_details_from_peers<F, Fut>(
peers: Vec<SocketAddr>,
id: String,
expected_version: Option<String>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
tx_notify_ui: UnboundedSender<PeerEvent>,
mut fetch_details: F,
) where
F: FnMut(SocketAddr, String, Arc<RwLock<PeerGameDB>>) -> Fut + Send + 'static,
Fut: Future<Output = eyre::Result<Vec<GameFileDescription>>> + Send,
{
let mut fetched_any = false;
for peer_addr in peers {
match fetch_details(peer_addr, id.clone(), 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, expected_version.as_deref())
};
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");
if let Err(e) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id: id.clone() }) {
log::error!("Failed to send DownloadGameFilesFailed event: {e}");
}
}
}
/// 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>,
install_after_download: bool,
) {
log::info!("Got PeerCommand::DownloadGameFiles");
if !catalog_contains(ctx, &id).await {
log::warn!("Ignoring download command for non-catalog game {id}");
if let Err(send_err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id }) {
log::error!("Failed to send DownloadGameFilesFailed event: {send_err}");
}
return;
}
let games_folder = { ctx.game_dir.read().await.clone() };
let expected_version = catalog_expected_version(ctx, &id).await;
// 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, expected_version.as_deref())
{
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"
);
if let Err(send_err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id }) {
log::error!("Failed to send DownloadGameFilesFailed event: {send_err}");
}
return;
}
let local_dl_available = {
let active_operations = ctx.active_operations.read().await;
let catalog = ctx.catalog.read().await;
local_download_matches_catalog(&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}");
}
if install_after_download {
spawn_install_operation(ctx, tx_notify_ui, id.clone());
}
} else {
log::error!("No trusted peers available after majority validation for game {id}");
if let Err(send_err) =
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id: id.clone() })
{
log::error!("Failed to send DownloadGameFilesFailed event: {send_err}");
}
}
return;
}
match begin_operation(ctx, tx_notify_ui, &id, OperationKind::Downloading).await {
BeginOperationResult::Started => {}
BeginOperationResult::AlreadyActive => {
log::warn!("Operation for {id} already in progress; ignoring new download request");
return;
}
BeginOperationResult::DrainTimedOut => {
log::error!("Timed out waiting for outbound transfers before downloading {id}");
send_download_failed(tx_notify_ui, &id);
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 download_state_guard = OperationGuard::download(
download_id.clone(),
active_operations,
active_downloads,
tx_notify_ui_clone.clone(),
);
let result = download_game_files(
&download_id,
resolved_descriptions,
games_folder,
peer_whitelist,
file_peer_map,
tx_notify_ui_clone.clone(),
cancel_token.clone(),
)
.await;
match result {
Ok(()) => {
let Some(prepared) =
prepare_install_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await
else {
if let Err(err) = refresh_local_game_for_ending_operation(
&ctx_clone,
&tx_notify_ui_clone,
&download_id,
)
.await
{
log::error!("Failed to refresh local library after download: {err}");
}
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
download_state_guard.disarm();
send_download_finished(&tx_notify_ui_clone, &download_id);
return;
};
if install_after_download {
if transition_download_to_install(
&ctx_clone,
&tx_notify_ui_clone,
&download_id,
prepared.operation_kind,
)
.await
{
clear_active_download(&ctx_clone, &download_id).await;
send_download_finished(&tx_notify_ui_clone, &download_id);
download_state_guard.disarm();
run_started_install_operation(
&ctx_clone,
&tx_notify_ui_clone,
download_id,
prepared,
)
.await;
} else {
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
download_state_guard.disarm();
send_download_finished(&tx_notify_ui_clone, &download_id);
}
} else {
if let Err(err) = refresh_local_game_for_ending_operation(
&ctx_clone,
&tx_notify_ui_clone,
&download_id,
)
.await
{
log::error!("Failed to refresh local library after download: {err}");
}
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
download_state_guard.disarm();
send_download_finished(&tx_notify_ui_clone, &download_id);
}
}
Err(e) => {
if let Err(refresh_err) = refresh_local_game_for_ending_operation(
&ctx_clone,
&tx_notify_ui_clone,
&download_id,
)
.await
{
log::error!(
"Failed to refresh local library after download failure: {refresh_err}"
);
}
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
download_state_guard.disarm();
let download_was_cancelled = cancel_token.is_cancelled();
if download_was_cancelled {
log::info!("Download cancelled for {download_id}: {e}");
} else {
log::error!("Download failed for {download_id}: {e}");
}
send_download_failed_unless_cancelled(
&tx_notify_ui_clone,
&download_id,
download_was_cancelled,
);
}
}
});
}
/// 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);
}
pub async fn handle_stream_install_game_command(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String,
) {
if !catalog_contains(ctx, &id).await {
log::warn!("Ignoring streamed install command for non-catalog game {id}");
send_download_failed(tx_notify_ui, &id);
return;
}
let games_folder = { ctx.game_dir.read().await.clone() };
let game_root = games_folder.join(&id);
if local_dir_is_directory(&game_root).await {
log::warn!("Ignoring streamed install command for already-installed game {id}");
send_download_failed(tx_notify_ui, &id);
return;
}
let expected_version = catalog_expected_version(ctx, &id).await;
let mut peers = {
match ctx
.peer_game_db
.read()
.await
.validate_file_sizes_majority(&id, expected_version.as_deref())
{
Ok((validated_files, peer_whitelist, _)) if !validated_files.is_empty() => {
peer_whitelist
}
Ok(_) => {
log::error!("No trusted peers available for streamed install of {id}");
send_download_failed(tx_notify_ui, &id);
return;
}
Err(err) => {
log::error!(
"File size majority validation failed for streamed install {id}: {err}"
);
send_download_failed(tx_notify_ui, &id);
return;
}
}
};
peers.sort();
let Some(peer_addr) = peers.into_iter().next() else {
log::error!("No peer selected for streamed install of {id}");
send_download_failed(tx_notify_ui, &id);
return;
};
match begin_operation(ctx, tx_notify_ui, &id, OperationKind::Downloading).await {
BeginOperationResult::Started => {}
BeginOperationResult::AlreadyActive => {
log::warn!("Operation for {id} already in progress; ignoring streamed install request");
return;
}
BeginOperationResult::DrainTimedOut => {
log::error!("Timed out waiting for outbound transfers before streamed install of {id}");
send_download_failed(tx_notify_ui, &id);
return;
}
}
let cancel_token = ctx.shutdown.child_token();
ctx.active_downloads
.write()
.await
.insert(id.clone(), cancel_token.clone());
let ctx_clone = ctx.clone();
let tx_notify_ui = tx_notify_ui.clone();
ctx.task_tracker.spawn(async move {
run_stream_install_operation(
ctx_clone,
tx_notify_ui,
id,
game_root,
peer_addr,
cancel_token,
)
.await;
});
}
/// 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;
});
}
pub async fn handle_remove_downloaded_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_remove_downloaded_operation(&ctx, &tx_notify_ui, id).await;
});
}
pub async fn handle_cancel_download_command(
ctx: &Ctx,
_tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String,
) {
let cancel_token = ctx.active_downloads.read().await.get(&id).cloned();
let Some(cancel_token) = cancel_token else {
log::warn!("Ignoring cancel request for inactive download {id}");
return;
};
log::info!("Cancelling download for game {id}");
cancel_token.cancel();
}
async fn run_stream_install_operation(
ctx: Ctx,
tx_notify_ui: UnboundedSender<PeerEvent>,
id: String,
game_root: PathBuf,
peer_addr: SocketAddr,
cancel_token: CancellationToken,
) {
let download_guard = OperationGuard::download(
id.clone(),
ctx.active_operations.clone(),
ctx.active_downloads.clone(),
tx_notify_ui.clone(),
);
events::send(
&tx_notify_ui,
PeerEvent::DownloadGameFilesBegin { id: id.clone() },
);
let transaction = match install::begin_streamed_install(&game_root, ctx.state_dir.as_ref(), &id)
.await
{
Ok(transaction) => transaction,
Err(err) => {
log::error!("Failed to prepare streamed install for {id}: {err}");
finish_failed_stream_download(&ctx, &tx_notify_ui, &id, download_guard, false).await;
return;
}
};
let receive_result = receive_streamed_install(
peer_addr,
&id,
transaction.staging_dir(),
tx_notify_ui.clone(),
cancel_token.clone(),
)
.await;
match receive_result {
Ok(()) => {
if transition_download_to_install(&ctx, &tx_notify_ui, &id, OperationKind::Installing)
.await
{
clear_active_download(&ctx, &id).await;
send_download_finished(&tx_notify_ui, &id);
download_guard.disarm();
commit_streamed_install(&ctx, &tx_notify_ui, id, transaction).await;
} else {
if let Err(err) = transaction.rollback().await {
log::error!("Failed to roll back streamed install for {id}: {err}");
}
finish_failed_stream_download(&ctx, &tx_notify_ui, &id, download_guard, false)
.await;
}
}
Err(err) => {
if let Err(rollback_err) = transaction.rollback().await {
log::error!("Failed to roll back streamed install for {id}: {rollback_err}");
}
let download_was_cancelled = cancel_token.is_cancelled();
if download_was_cancelled {
log::info!("Streamed install download cancelled for {id}: {err}");
} else {
log::error!("Streamed install download failed for {id}: {err}");
}
finish_failed_stream_download(
&ctx,
&tx_notify_ui,
&id,
download_guard,
download_was_cancelled,
)
.await;
}
}
}
async fn finish_failed_stream_download(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: &str,
guard: OperationGuard,
cancelled: bool,
) {
if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, id).await {
log::error!("Failed to refresh local library after streamed install failure: {err}");
}
end_download_operation(ctx, tx_notify_ui, id).await;
guard.disarm();
send_download_failed_unless_cancelled(tx_notify_ui, id, cancelled);
}
async fn commit_streamed_install(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String,
transaction: install::StreamedInstallTransaction,
) {
let operation_guard = OperationGuard::new(
id.clone(),
ctx.active_operations.clone(),
tx_notify_ui.clone(),
);
events::send(
tx_notify_ui,
PeerEvent::InstallGameBegin {
id: id.clone(),
operation: InstallOperation::Installing,
},
);
match transaction.commit().await {
Ok(()) => {
if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
{
log::error!("Failed to refresh local library after streamed install: {err}");
}
end_operation(ctx, tx_notify_ui, &id).await;
operation_guard.disarm();
events::send(
tx_notify_ui,
PeerEvent::InstallGameFinished { id: id.clone() },
);
}
Err(err) => {
log::error!("Streamed install commit failed for {id}: {err}");
if let Err(refresh_err) =
refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
{
log::error!(
"Failed to refresh local library after streamed install commit failure: {refresh_err}"
);
}
end_operation(ctx, tx_notify_ui, &id).await;
operation_guard.disarm();
events::send(
tx_notify_ui,
PeerEvent::InstallGameFailed { id: id.clone() },
);
}
}
}
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) {
let Some(prepared) = prepare_install_operation(ctx, tx_notify_ui, &id).await else {
return;
};
match begin_operation(ctx, tx_notify_ui, &id, prepared.operation_kind).await {
BeginOperationResult::Started => {}
BeginOperationResult::AlreadyActive => {
log::warn!("Operation for {id} already in progress; ignoring install command");
return;
}
BeginOperationResult::DrainTimedOut => {
log::error!("Timed out waiting for outbound transfers before install/update of {id}");
events::send(
tx_notify_ui,
PeerEvent::InstallGameFailed { id: id.clone() },
);
return;
}
}
run_started_install_operation(ctx, tx_notify_ui, id, prepared).await;
}
struct PreparedInstallOperation {
game_root: PathBuf,
operation: InstallOperation,
operation_kind: OperationKind,
}
async fn prepare_install_operation(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: &str,
) -> Option<PreparedInstallOperation> {
if !catalog_contains(ctx, id).await {
log::warn!("Ignoring install command for non-catalog game {id}");
return None;
}
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: id.to_string() },
);
return None;
}
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,
};
Some(PreparedInstallOperation {
game_root,
operation,
operation_kind,
})
}
async fn run_started_install_operation(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String,
prepared: PreparedInstallOperation,
) {
let PreparedInstallOperation {
game_root,
operation,
..
} = prepared;
let operation_guard = OperationGuard::new(
id.clone(),
ctx.active_operations.clone(),
tx_notify_ui.clone(),
);
let result = {
events::send(
tx_notify_ui,
PeerEvent::InstallGameBegin {
id: id.clone(),
operation,
},
);
let state_dir = ctx.state_dir.as_ref();
match operation {
InstallOperation::Installing => {
install::install(&game_root, state_dir, &id, ctx.unpacker.clone()).await
}
InstallOperation::Updating => {
install::update(&game_root, state_dir, &id, ctx.unpacker.clone()).await
}
}
};
match result {
Ok(()) => {
if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
{
log::error!("Failed to refresh local library after install: {err}");
}
end_operation(ctx, tx_notify_ui, &id).await;
operation_guard.disarm();
events::send(
tx_notify_ui,
PeerEvent::InstallGameFinished { id: id.clone() },
);
}
Err(err) => {
log::error!("Install operation failed for {id}: {err}");
if let Err(refresh_err) =
refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
{
log::error!("Failed to refresh local library after install failure: {refresh_err}");
}
end_operation(ctx, tx_notify_ui, &id).await;
operation_guard.disarm();
events::send(
tx_notify_ui,
PeerEvent::InstallGameFailed { id: id.clone() },
);
}
}
}
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;
}
match begin_operation(ctx, tx_notify_ui, &id, OperationKind::Uninstalling).await {
BeginOperationResult::Started => {}
BeginOperationResult::AlreadyActive => {
log::warn!("Operation for {id} already in progress; ignoring uninstall command");
return;
}
BeginOperationResult::DrainTimedOut => {
log::error!("Timed out waiting for outbound transfers before uninstall of {id}");
events::send(
tx_notify_ui,
PeerEvent::UninstallGameFailed { id: id.clone() },
);
return;
}
}
let game_root = { ctx.game_dir.read().await.join(&id) };
let operation_guard = OperationGuard::new(
id.clone(),
ctx.active_operations.clone(),
tx_notify_ui.clone(),
);
let result = {
events::send(
tx_notify_ui,
PeerEvent::UninstallGameBegin { id: id.clone() },
);
install::uninstall(&game_root, ctx.state_dir.as_ref(), &id).await
};
match result {
Ok(()) => {
if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
{
log::error!("Failed to refresh local library after uninstall: {err}");
}
end_operation(ctx, tx_notify_ui, &id).await;
operation_guard.disarm();
events::send(
tx_notify_ui,
PeerEvent::UninstallGameFinished { id: id.clone() },
);
}
Err(err) => {
log::error!("Uninstall operation failed for {id}: {err}");
if let Err(refresh_err) =
refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
{
log::error!(
"Failed to refresh local library after uninstall failure: {refresh_err}"
);
}
end_operation(ctx, tx_notify_ui, &id).await;
operation_guard.disarm();
events::send(
tx_notify_ui,
PeerEvent::UninstallGameFailed { id: id.clone() },
);
}
}
}
async fn run_remove_downloaded_operation(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String,
) {
if !catalog_contains(ctx, &id).await {
log::warn!("Ignoring downloaded-file removal for non-catalog game {id}");
return;
}
match begin_operation(ctx, tx_notify_ui, &id, OperationKind::RemovingDownload).await {
BeginOperationResult::Started => {}
BeginOperationResult::AlreadyActive => {
log::warn!("Operation for {id} already in progress; ignoring downloaded-file removal");
return;
}
BeginOperationResult::DrainTimedOut => {
log::error!("Timed out waiting for outbound transfers before removal of {id}");
events::send(
tx_notify_ui,
PeerEvent::RemoveDownloadedGameFailed { id: id.clone() },
);
return;
}
}
let game_dir = { ctx.game_dir.read().await.clone() };
let operation_guard = OperationGuard::new(
id.clone(),
ctx.active_operations.clone(),
tx_notify_ui.clone(),
);
let result = {
events::send(
tx_notify_ui,
PeerEvent::RemoveDownloadedGameBegin { id: id.clone() },
);
install::remove_downloaded(&game_dir, &id).await
};
match result {
Ok(()) => {
if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
{
log::error!("Failed to refresh local library after downloaded-file removal: {err}");
}
end_operation(ctx, tx_notify_ui, &id).await;
operation_guard.disarm();
events::send(
tx_notify_ui,
PeerEvent::RemoveDownloadedGameFinished { id: id.clone() },
);
}
Err(err) => {
log::error!("Downloaded-file removal failed for {id}: {err}");
if let Err(refresh_err) =
refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
{
log::error!(
"Failed to refresh local library after downloaded-file removal failure: {refresh_err}"
);
}
end_operation(ctx, tx_notify_ui, &id).await;
operation_guard.disarm();
events::send(
tx_notify_ui,
PeerEvent::RemoveDownloadedGameFailed { id: id.clone() },
);
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum BeginOperationResult {
Started,
AlreadyActive,
DrainTimedOut,
}
async fn begin_operation(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: &str,
operation: OperationKind,
) -> BeginOperationResult {
begin_operation_with_drain_timeout(
ctx,
tx_notify_ui,
id,
operation,
OUTBOUND_TRANSFER_DRAIN_TIMEOUT,
)
.await
}
async fn begin_operation_with_drain_timeout(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: &str,
operation: OperationKind,
drain_timeout: Duration,
) -> BeginOperationResult {
let started = {
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,
}
};
if !started {
return BeginOperationResult::AlreadyActive;
}
events::emit_active_operations(&ctx.active_operations, tx_notify_ui).await;
if operation_requires_outbound_drain(operation)
&& !cancel_and_wait_for_outbound_transfers(ctx, id, drain_timeout).await
{
end_operation(ctx, tx_notify_ui, id).await;
return BeginOperationResult::DrainTimedOut;
}
BeginOperationResult::Started
}
fn operation_requires_outbound_drain(operation: OperationKind) -> bool {
operation == OperationKind::Updating || operation == OperationKind::RemovingDownload
}
async fn cancel_and_wait_for_outbound_transfers(
ctx: &Ctx,
id: &str,
drain_timeout: Duration,
) -> bool {
let mut tokens_to_cancel = Vec::new();
{
let active = ctx.active_outbound_transfers.read().await;
if let Some(transfers) = active.get(id) {
for (_, token) in transfers {
tokens_to_cancel.push(token.clone());
}
}
}
for token in tokens_to_cancel {
token.cancel();
}
let drained = tokio::time::timeout(drain_timeout, async {
loop {
let count = {
let active = ctx.active_outbound_transfers.read().await;
active.get(id).map_or(0, Vec::len)
};
if count == 0 {
break;
}
tokio::time::sleep(OUTBOUND_TRANSFER_DRAIN_POLL_INTERVAL).await;
}
})
.await
.is_ok();
if !drained {
let count = {
let active = ctx.active_outbound_transfers.read().await;
active.get(id).map_or(0, Vec::len)
};
log::error!(
"Timed out after {drain_timeout:?} waiting for {count} outbound transfer(s) to drain for {id}"
);
}
drained
}
async fn transition_download_to_install(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: &str,
operation: OperationKind,
) -> bool {
let transitioned = {
let mut active_operations = ctx.active_operations.write().await;
match active_operations.get_mut(id) {
Some(current) if *current == OperationKind::Downloading => {
*current = operation;
true
}
Some(current) => {
log::warn!(
"Cannot transition {id} from download to install; current operation is {current:?}"
);
false
}
None => {
log::warn!(
"Cannot transition {id} from download to install; operation is not active"
);
false
}
}
};
if transitioned {
events::emit_active_operations(&ctx.active_operations, tx_notify_ui).await;
}
transitioned
}
async fn end_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: &str) {
if ctx.active_operations.write().await.remove(id).is_some() {
events::emit_active_operations(&ctx.active_operations, tx_notify_ui).await;
}
}
async fn clear_active_download(ctx: &Ctx, id: &str) {
ctx.active_downloads.write().await.remove(id);
}
fn send_download_finished(tx_notify_ui: &UnboundedSender<PeerEvent>, id: &str) {
if let Err(err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFinished { id: id.into() }) {
log::error!("Failed to send DownloadGameFilesFinished event: {err}");
}
}
fn send_download_failed(tx_notify_ui: &UnboundedSender<PeerEvent>, id: &str) {
if let Err(err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id: id.into() }) {
log::error!("Failed to send DownloadGameFilesFailed event: {err}");
}
}
fn send_download_failed_unless_cancelled(
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: &str,
cancelled: bool,
) -> bool {
if cancelled {
return false;
}
send_download_failed(tx_notify_ui, id);
true
}
async fn end_download_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: &str) {
end_operation(ctx, tx_notify_ui, id).await;
clear_active_download(ctx, id).await;
}
async fn catalog_contains(ctx: &Ctx, id: &str) -> bool {
ctx.catalog.read().await.contains(id)
}
async fn catalog_expected_version(ctx: &Ctx, id: &str) -> Option<String> {
ctx.catalog
.read()
.await
.expected_version(id)
.map(ToOwned::to_owned)
}
/// 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_with_policy(
&ctx_clone,
&tx_notify_ui,
LocalLibraryEventPolicy::ForceSnapshot,
)
.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<()> {
load_local_library_with_policy(ctx, tx_notify_ui, LocalLibraryEventPolicy::OnChange).await
}
async fn load_local_library_with_policy(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
event_policy: LocalLibraryEventPolicy,
) -> 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, ctx.state_dir.as_ref(), &active_ids).await?;
scan_and_announce_local_library(ctx, tx_notify_ui, &game_dir, event_policy).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,
LocalLibraryEventPolicy::OnChange,
)
.await
}
async fn scan_and_announce_local_library(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
game_dir: &Path,
event_policy: LocalLibraryEventPolicy,
) -> eyre::Result<()> {
let catalog = ctx.catalog.read().await.clone();
let scan = scan_local_library(game_dir, ctx.state_dir.as_ref(), &catalog).await?;
update_and_announce_games_with_policy(ctx, tx_notify_ui, scan, event_policy, None).await;
Ok(())
}
/// Refreshes the game whose operation has completed before clearing its
/// active-operation snapshot, while preserving freeze behavior for other games.
async fn refresh_local_game_for_ending_operation(
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, ctx.state_dir.as_ref(), &catalog, id).await?;
update_and_announce_games_with_policy(
ctx,
tx_notify_ui,
scan,
LocalLibraryEventPolicy::OnChange,
Some(id),
)
.await;
Ok(())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum LocalLibraryEventPolicy {
OnChange,
ForceSnapshot,
}
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;
}
/// Connects to a peer directly, bypassing mDNS discovery.
pub async fn handle_connect_peer_command(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
addr: SocketAddr,
) {
log::info!("Direct connect command received for {addr}");
let handshake_ctx = HandshakeCtx::from_ctx(ctx, tx_notify_ui);
ctx.task_tracker.spawn(async move {
if let Err(err) = perform_handshake_with_peer(handshake_ctx, addr, None).await {
log::warn!("Failed direct connect to {addr}: {err}");
}
});
}
// =============================================================================
// 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,
) {
update_and_announce_games_with_policy(
ctx,
tx_notify_ui,
scan,
LocalLibraryEventPolicy::OnChange,
None,
)
.await;
}
async fn update_and_announce_games_with_policy(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
scan: LocalLibraryScan,
event_policy: LocalLibraryEventPolicy,
ending_operation_id: Option<&str>,
) {
let LocalLibraryScan {
mut game_db,
mut summaries,
revision,
} = scan;
let mut active_operation_ids = active_operation_ids(ctx).await;
if let Some(id) = ending_operation_id {
active_operation_ids.remove(id);
}
if !active_operation_ids.is_empty() {
for id in &active_operation_ids {
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 delta.is_some() || event_policy == LocalLibraryEventPolicy::ForceSnapshot {
events::send(
tx_notify_ui,
PeerEvent::LocalLibraryChanged {
games: all_games.clone(),
},
);
} else {
log::debug!("Skipping unchanged local library event");
}
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)| addr)
.collect::<Vec<_>>()
};
for peer_addr in peer_targets {
let delta = delta.clone();
let peer_id = ctx.peer_id.as_ref().clone();
ctx.task_tracker.spawn(async move {
if let Err(e) = send_library_delta(peer_addr, &peer_id, delta).await {
log::warn!("Failed to send library delta to {peer_addr}: {e}");
}
});
}
}
#[cfg(test)]
mod tests {
use std::{
collections::HashMap,
net::SocketAddr,
path::{Path, PathBuf},
sync::{Arc, Mutex},
time::Duration,
};
use lanspread_db::db::GameCatalog;
use lanspread_proto::{Availability, GameSummary};
use tokio::sync::mpsc;
use tokio_util::{sync::CancellationToken, task::TaskTracker};
use super::*;
use crate::{
ActiveOperation,
ActiveOperationKind,
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.clone(),
game_dir.join(".test-state"),
Arc::new(FakeUnpacker),
CancellationToken::new(),
TaskTracker::new(),
Arc::new(RwLock::new(GameCatalog::from_ids(["game".to_string()]))),
Arc::new(RwLock::new(HashMap::new())),
Arc::new(crate::NoopStreamInstallProvider),
)
}
#[test]
fn cancelled_download_error_does_not_emit_failed_event() {
let (tx, mut rx) = mpsc::unbounded_channel();
let emitted = send_download_failed_unless_cancelled(&tx, "game", true);
assert!(!emitted);
assert!(rx.try_recv().is_err());
}
#[test]
fn uncancelled_download_error_emits_failed_event() {
let (tx, mut rx) = mpsc::unbounded_channel();
let emitted = send_download_failed_unless_cancelled(&tx, "game", false);
assert!(emitted);
assert!(matches!(
rx.try_recv(),
Ok(PeerEvent::DownloadGameFilesFailed { id }) if id == "game"
));
}
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")
}
async fn assert_no_event(rx: &mut mpsc::UnboundedReceiver<PeerEvent>) {
assert!(
tokio::time::timeout(Duration::from_millis(50), rx.recv())
.await
.is_err(),
"event channel should stay quiet"
);
}
fn addr(port: u16) -> SocketAddr {
SocketAddr::from(([127, 0, 0, 1], port))
}
fn summary(id: &str, version: &str, availability: Availability) -> GameSummary {
GameSummary {
id: id.to_string(),
name: id.to_string(),
size: 42,
downloaded: availability == Availability::Ready,
installed: true,
eti_version: Some(version.to_string()),
manifest_hash: 7,
availability,
}
}
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,
}
}
fn assert_local_update(event: PeerEvent, installed: bool, downloaded: bool) {
let _ = local_update_game(event, installed, downloaded);
}
fn local_update_game(
event: PeerEvent,
installed: bool,
downloaded: bool,
) -> lanspread_db::db::Game {
let PeerEvent::LocalLibraryChanged { games } = event else {
panic!("expected LocalLibraryChanged");
};
let game = games
.into_iter()
.find(|game| game.id == "game")
.expect("game should be announced");
assert_eq!(game.installed, installed);
assert_eq!(game.downloaded, downloaded);
game
}
fn assert_active_update(event: PeerEvent, expected: Vec<ActiveOperation>) {
let PeerEvent::ActiveOperationsChanged { active_operations } = event else {
panic!("expected ActiveOperationsChanged");
};
assert_eq!(active_operations, expected);
}
fn active_update(id: &str, operation: ActiveOperationKind) -> Vec<ActiveOperation> {
vec![ActiveOperation {
id: id.to_string(),
operation,
}]
}
#[test]
fn update_source_selects_expected_ready_peer_manifest() {
let old_addr = addr(12_000);
let new_addr = addr(12_001);
let local_only_addr = addr(12_002);
let mut db = PeerGameDB::new();
db.upsert_peer("old".to_string(), old_addr);
db.upsert_peer("new".to_string(), new_addr);
db.upsert_peer("local-only".to_string(), local_only_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_games(
&"local-only".to_string(),
vec![summary("game", "20990101", Availability::LocalOnly)],
);
assert_eq!(
GameDetailSource::LatestPeersOnly.select_peers(&db, "game", Some("20250101")),
vec![new_addr]
);
}
#[tokio::test]
async fn update_fetch_emits_fresh_manifest_from_expected_peer() {
let old_addr = addr(12_010);
let new_addr = addr(12_011);
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
{
let mut db = peer_game_db.write().await;
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)],
);
}
let peers = {
let db = peer_game_db.read().await;
GameDetailSource::LatestPeersOnly.select_peers(&db, "game", Some("20250101"))
};
let (tx, mut rx) = mpsc::unbounded_channel();
let fetched_peers = Arc::new(Mutex::new(Vec::new()));
fetch_game_details_from_peers(
peers,
"game".to_string(),
Some("20250101".to_string()),
peer_game_db.clone(),
tx,
{
let fetched_peers = fetched_peers.clone();
move |peer_addr, game_id, peer_game_db| {
let fetched_peers = fetched_peers.clone();
async move {
fetched_peers
.lock()
.expect("fetched peer list should not be poisoned")
.push(peer_addr);
let files = vec![
file_desc(&game_id, "game/version.ini", 8),
file_desc(&game_id, "game/new.eti", 11),
];
peer_game_db.write().await.update_peer_game_files(
&"new".to_string(),
&game_id,
files.clone(),
);
Ok(files)
}
}
},
)
.await;
assert_eq!(
*fetched_peers
.lock()
.expect("fetched peer list should not be poisoned"),
vec![new_addr]
);
let PeerEvent::GotGameFiles {
id,
file_descriptions,
} = recv_event(&mut rx).await
else {
panic!("expected GotGameFiles");
};
assert_eq!(id, "game");
assert!(
file_descriptions
.iter()
.any(|desc| desc.relative_path == "game/new.eti" && desc.size == 11),
"expected-version peer manifest should be emitted to the download path"
);
}
#[tokio::test]
async fn failed_peer_detail_fetch_emits_terminal_download_failure() {
let first_addr = addr(12_020);
let second_addr = addr(12_021);
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
let (tx, mut rx) = mpsc::unbounded_channel();
let fetched_peers = Arc::new(Mutex::new(Vec::new()));
fetch_game_details_from_peers(
vec![first_addr, second_addr],
"game".to_string(),
Some("20250101".to_string()),
peer_game_db,
tx.clone(),
{
let fetched_peers = fetched_peers.clone();
move |peer_addr, _game_id, _peer_game_db| {
let fetched_peers = fetched_peers.clone();
async move {
fetched_peers
.lock()
.expect("fetched peer list should not be poisoned")
.push(peer_addr);
Err::<Vec<GameFileDescription>, _>(eyre::eyre!("detail fetch failed"))
}
}
},
)
.await;
assert_eq!(
*fetched_peers
.lock()
.expect("fetched peer list should not be poisoned"),
vec![first_addr, second_addr]
);
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::DownloadGameFilesFailed { id } if id == "game"
));
assert_no_event(&mut rx).await;
}
#[tokio::test]
async fn update_request_skips_local_manifest_even_when_download_exists() {
let temp = TempDir::new("lanspread-handler-expected-peer");
let root = temp.game_root();
write_file(&root.join("version.ini"), b"20240101");
write_file(&root.join("game.eti"), b"old archive");
let ctx = test_ctx(temp.path().to_path_buf());
let (tx, mut rx) = mpsc::unbounded_channel();
handle_get_game_command(
&ctx,
&tx,
"game".to_string(),
GameDetailSource::LatestPeersOnly,
)
.await;
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::NoPeersHaveGame { id } if id == "game"
));
}
#[tokio::test]
async fn local_library_scan_hides_active_game_state() {
let temp = TempDir::new("lanspread-handler-active-hide");
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();
// 1. Initial scan: the game is ready and announced
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
.await
.expect("scan should succeed");
update_and_announce_games(&ctx, &tx, scan).await;
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
panic!("expected LocalLibraryChanged");
};
assert_eq!(games.len(), 1);
assert_eq!(games[0].id, "game");
// 2. Set the game as active/in-progress and scan again
ctx.active_operations
.write()
.await
.insert("game".to_string(), OperationKind::Installing);
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
.await
.expect("scan should succeed");
update_and_announce_games(&ctx, &tx, scan).await;
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
panic!("expected LocalLibraryChanged");
};
assert!(
games.is_empty(),
"active game should be hidden/unannounced during operations"
);
}
#[tokio::test]
async fn begin_operation_reports_authoritative_active_operation_snapshot() {
let temp = TempDir::new("lanspread-handler-active-begin");
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();
assert_eq!(
begin_operation(&ctx, &tx, "game", OperationKind::Updating).await,
BeginOperationResult::Started
);
assert_active_update(
recv_event(&mut rx).await,
vec![ActiveOperation {
id: "game".to_string(),
operation: ActiveOperationKind::Updating,
}],
);
}
#[tokio::test]
async fn begin_operation_timeout_clears_active_operation_snapshot() {
let temp = TempDir::new("lanspread-handler-active-drain-timeout");
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 token = CancellationToken::new();
ctx.active_outbound_transfers
.write()
.await
.insert("game".to_string(), vec![(1, token.clone())]);
assert_eq!(
begin_operation_with_drain_timeout(
&ctx,
&tx,
"game",
OperationKind::Updating,
Duration::from_millis(1),
)
.await,
BeginOperationResult::DrainTimedOut
);
assert!(token.is_cancelled());
assert_active_update(
recv_event(&mut rx).await,
vec![ActiveOperation {
id: "game".to_string(),
operation: ActiveOperationKind::Updating,
}],
);
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(
!ctx.active_operations.read().await.contains_key("game"),
"timed-out drain should not leave the operation stuck active"
);
}
#[tokio::test]
async fn unchanged_settled_scan_is_not_reemitted() {
let temp = TempDir::new("lanspread-handler-settled-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(), ctx.state_dir.as_ref(), &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);
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
.await
.expect("second scan should succeed");
update_and_announce_games(&ctx, &tx, scan).await;
assert_no_event(&mut rx).await;
}
#[tokio::test]
async fn unchanged_operation_refresh_still_reports_settled_snapshot() {
let temp = TempDir::new("lanspread-handler-operation-unchanged");
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();
let catalog = ctx.catalog.read().await.clone();
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
.await
.expect("initial scan should succeed");
update_and_announce_games(&ctx, &tx, scan).await;
assert_local_update(recv_event(&mut rx).await, true, true);
run_install_operation(&ctx, &tx, "game".to_string()).await;
assert_active_update(
recv_event(&mut rx).await,
active_update("game", ActiveOperationKind::Updating),
);
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameBegin {
id,
operation: InstallOperation::Updating
} if id == "game"
));
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameFinished { id } if id == "game"
));
assert_no_event(&mut rx).await;
}
#[tokio::test]
async fn install_refreshes_settled_state_before_operation_clear() {
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;
assert_active_update(
recv_event(&mut rx).await,
active_update("game", ActiveOperationKind::Installing),
);
match recv_event(&mut rx).await {
PeerEvent::InstallGameBegin { id, operation } => {
assert_eq!(id, "game");
assert_eq!(operation, InstallOperation::Installing);
}
_ => panic!("expected InstallGameBegin"),
}
assert_local_update(recv_event(&mut rx).await, true, true);
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameFinished { id } if id == "game"
));
assert!(ctx.active_operations.read().await.is_empty());
}
#[tokio::test]
async fn download_handoff_waits_for_readers_and_auto_installs() {
let temp = TempDir::new("lanspread-handler-download-handoff");
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::Downloading);
ctx.active_downloads
.write()
.await
.insert("game".to_string(), CancellationToken::new());
let (prepare_tx, _prepare_rx) = mpsc::unbounded_channel();
let prepared = prepare_install_operation(&ctx, &prepare_tx, "game")
.await
.expect("downloaded game should be installable");
let read_guard = ctx.active_operations.read().await;
let (tx, mut rx) = mpsc::unbounded_channel();
let install_task = tokio::spawn({
let ctx = ctx.clone();
let tx = tx.clone();
async move {
assert!(
transition_download_to_install(&ctx, &tx, "game", prepared.operation_kind)
.await
);
clear_active_download(&ctx, "game").await;
run_started_install_operation(&ctx, &tx, "game".to_string(), prepared).await;
}
});
tokio::task::yield_now().await;
assert_eq!(read_guard.get("game"), Some(&OperationKind::Downloading));
drop(read_guard);
install_task.await.expect("handoff task should finish");
assert_active_update(
recv_event(&mut rx).await,
active_update("game", ActiveOperationKind::Installing),
);
match recv_event(&mut rx).await {
PeerEvent::InstallGameBegin { id, operation } => {
assert_eq!(id, "game");
assert_eq!(operation, InstallOperation::Installing);
}
_ => panic!("expected InstallGameBegin"),
}
assert_local_update(recv_event(&mut rx).await, true, true);
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameFinished { id } if id == "game"
));
assert!(ctx.active_operations.read().await.is_empty());
assert!(ctx.active_downloads.read().await.is_empty());
}
#[tokio::test]
async fn cancel_download_command_only_cancels_active_token() {
let temp = TempDir::new("lanspread-handler-cancel-download");
let ctx = test_ctx(temp.path().to_path_buf());
let cancel = CancellationToken::new();
ctx.active_operations
.write()
.await
.insert("game".to_string(), OperationKind::Downloading);
ctx.active_downloads
.write()
.await
.insert("game".to_string(), cancel.clone());
let (tx, mut rx) = mpsc::unbounded_channel();
handle_cancel_download_command(&ctx, &tx, "game".to_string()).await;
assert!(cancel.is_cancelled());
assert_eq!(
ctx.active_operations.read().await.get("game"),
Some(&OperationKind::Downloading),
"the running transfer owns operation cleanup after cancellation"
);
assert_no_event(&mut rx).await;
}
#[tokio::test]
async fn update_refreshes_settled_state_before_operation_clear() {
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;
assert_active_update(
recv_event(&mut rx).await,
active_update("game", ActiveOperationKind::Updating),
);
match recv_event(&mut rx).await {
PeerEvent::InstallGameBegin { id, operation } => {
assert_eq!(id, "game");
assert_eq!(operation, InstallOperation::Updating);
}
_ => panic!("expected InstallGameBegin"),
}
assert_local_update(recv_event(&mut rx).await, true, true);
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameFinished { id } if id == "game"
));
assert!(ctx.active_operations.read().await.is_empty());
}
#[tokio::test]
async fn install_update_uninstall_sequence_reports_new_version_and_settled_state() {
let temp = TempDir::new("lanspread-handler-sequence");
let root = temp.game_root();
write_file(&root.join("version.ini"), b"20240101");
write_file(&root.join("game.eti"), b"old 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;
assert_active_update(
recv_event(&mut rx).await,
active_update("game", ActiveOperationKind::Installing),
);
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameBegin {
id,
operation: InstallOperation::Installing
} if id == "game"
));
let game = local_update_game(recv_event(&mut rx).await, true, true);
assert_eq!(game.local_version.as_deref(), Some("20240101"));
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameFinished { id } if id == "game"
));
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("game.eti"), b"new archive");
run_install_operation(&ctx, &tx, "game".to_string()).await;
assert_active_update(
recv_event(&mut rx).await,
active_update("game", ActiveOperationKind::Updating),
);
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameBegin {
id,
operation: InstallOperation::Updating
} if id == "game"
));
let game = local_update_game(recv_event(&mut rx).await, true, true);
assert_eq!(game.local_version.as_deref(), Some("20250101"));
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameFinished { id } if id == "game"
));
run_uninstall_operation(&ctx, &tx, "game".to_string()).await;
assert_active_update(
recv_event(&mut rx).await,
active_update("game", ActiveOperationKind::Uninstalling),
);
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::UninstallGameBegin { id } if id == "game"
));
let game = local_update_game(recv_event(&mut rx).await, false, true);
assert_eq!(game.local_version.as_deref(), Some("20250101"));
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::UninstallGameFinished { id } if id == "game"
));
assert!(ctx.active_operations.read().await.is_empty());
}
#[tokio::test]
async fn uninstall_refreshes_settled_state_before_operation_clear() {
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_active_update(
recv_event(&mut rx).await,
active_update("game", ActiveOperationKind::Uninstalling),
);
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::UninstallGameBegin { id } if id == "game"
));
assert_local_update(recv_event(&mut rx).await, false, true);
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::UninstallGameFinished { id } if id == "game"
));
assert!(ctx.active_operations.read().await.is_empty());
}
#[tokio::test]
async fn remove_downloaded_refreshes_settled_state_before_operation_clear() {
let temp = TempDir::new("lanspread-handler-remove-downloaded");
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(), ctx.state_dir.as_ref(), &catalog)
.await
.expect("initial scan should succeed");
update_and_announce_games(&ctx, &tx, scan).await;
assert_local_update(recv_event(&mut rx).await, false, true);
run_remove_downloaded_operation(&ctx, &tx, "game".to_string()).await;
assert_active_update(
recv_event(&mut rx).await,
active_update("game", ActiveOperationKind::RemovingDownload),
);
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::RemoveDownloadedGameBegin { id } if id == "game"
));
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
panic!("expected LocalLibraryChanged");
};
assert!(games.is_empty());
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::RemoveDownloadedGameFinished { id } if id == "game"
));
assert!(!root.exists());
assert!(ctx.active_operations.read().await.is_empty());
}
#[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());
}
#[tokio::test]
async fn path_changing_set_game_dir_emits_equivalent_snapshot() {
let current = TempDir::new("lanspread-handler-old-equivalent-dir");
let next = TempDir::new("lanspread-handler-new-equivalent-dir");
for root in [current.game_root(), next.game_root()] {
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("game.eti"), b"archive");
}
let ctx = test_ctx(current.path().to_path_buf());
let (tx, mut rx) = mpsc::unbounded_channel();
let catalog = ctx.catalog.read().await.clone();
let scan = scan_local_library(current.path(), ctx.state_dir.as_ref(), &catalog)
.await
.expect("initial scan should succeed");
update_and_announce_games(&ctx, &tx, scan).await;
assert_local_update(recv_event(&mut rx).await, false, true);
handle_set_game_dir_command(&ctx, &tx, next.path().to_path_buf()).await;
ctx.task_tracker.close();
ctx.task_tracker.wait().await;
assert_local_update(recv_event(&mut rx).await, false, true);
}
}