Record the deterministic nightly rustfmt output for the newly added event-channel and retry code after the repository-wide formatter gate was repaired. Test Plan: - just fmt - just clippy - git diff --check
5227 lines
183 KiB
Rust
5227 lines
183 KiB
Rust
//! Command handlers for peer commands.
|
|
|
|
use std::{
|
|
collections::{HashMap, HashSet},
|
|
fmt,
|
|
future::Future,
|
|
path::{Path, PathBuf},
|
|
sync::Arc,
|
|
time::Duration,
|
|
};
|
|
|
|
use lanspread_db::{
|
|
content_manifest::{CatalogContentManifest, ContentId},
|
|
db::GameDB,
|
|
};
|
|
use lanspread_proto::PeerEndpoint;
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
#[cfg(test)]
|
|
use crate::local_games::{rescan_local_game, scan_local_library};
|
|
use crate::{
|
|
DownloadAttemptKey,
|
|
DownloadFailureReason,
|
|
DownloadVerificationActivity,
|
|
InstallOperation,
|
|
PeerEvent,
|
|
PeerEventSender,
|
|
StreamInstallSettings,
|
|
apply_launch_settings_to_verified_tree,
|
|
content_quarantine::ContentQuarantine,
|
|
context::{Ctx, OperationGuard, OperationKind},
|
|
download::{
|
|
DownloadCompletion,
|
|
DownloadGameRequest,
|
|
DownloadOwnershipReadiness,
|
|
ValidatedDownloadManifest,
|
|
download_game_files,
|
|
download_ownership_matches_content,
|
|
download_ownership_readiness,
|
|
remove_downloaded_payload,
|
|
},
|
|
events,
|
|
install,
|
|
local_games::{
|
|
LocalLibraryScan,
|
|
game_from_summary,
|
|
local_dir_is_directory,
|
|
local_download_matches_catalog,
|
|
rescan_local_game_with_recovery_failures,
|
|
scan_local_library_with_recovery_failures,
|
|
version_ini_is_regular_file,
|
|
},
|
|
mark_launch_settings_applied,
|
|
peer_db::PeerGameDB,
|
|
quic_runtime::QuicConnector,
|
|
scoped_blocking::scoped_blocking,
|
|
services::{HandshakeCtx, ReservedCandidateHandshake},
|
|
stream_install::{
|
|
ReceiveStreamedInstallRequest,
|
|
StreamInstallReceiveError,
|
|
StreamInstallReceiveErrorKind,
|
|
receive_streamed_install,
|
|
},
|
|
transfer_status::{DownloadAttemptReporter, DownloadAttemptStatus},
|
|
};
|
|
|
|
// =============================================================================
|
|
// Command handlers
|
|
// =============================================================================
|
|
|
|
const OUTBOUND_TRANSFER_DRAIN_POLL_INTERVAL: Duration = Duration::from_millis(10);
|
|
const OUTBOUND_TRANSFER_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
|
|
|
|
async fn retain_guard_until_operation_completes<G, F>(guard: G, operation: F) -> F::Output
|
|
where
|
|
F: Future,
|
|
{
|
|
let output = operation.await;
|
|
drop(guard);
|
|
output
|
|
}
|
|
|
|
async fn register_download_attempt(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &PeerEventSender,
|
|
key: DownloadAttemptKey,
|
|
cancellation: CancellationToken,
|
|
) -> DownloadAttemptStatus {
|
|
let status = DownloadAttemptStatus::new(key, cancellation, tx_notify_ui.clone());
|
|
ctx.active_downloads
|
|
.write()
|
|
.await
|
|
.insert(status.key().id.clone(), status.signal());
|
|
status
|
|
}
|
|
|
|
/// Immutable filesystem target selected when a command enters the peer core.
|
|
///
|
|
/// The configured games directory may change while a spawned operation waits to
|
|
/// run. Admission compares this captured target with the configured directory,
|
|
/// and every later filesystem step uses these paths rather than rereading
|
|
/// `Ctx::game_dir`.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
struct OperationTarget {
|
|
games_folder: PathBuf,
|
|
game_id: String,
|
|
}
|
|
|
|
impl OperationTarget {
|
|
fn new(games_folder: PathBuf, game_id: String) -> Self {
|
|
Self {
|
|
games_folder,
|
|
game_id,
|
|
}
|
|
}
|
|
|
|
fn game_id(&self) -> &str {
|
|
&self.game_id
|
|
}
|
|
|
|
fn game_root(&self) -> PathBuf {
|
|
self.games_folder.join(&self.game_id)
|
|
}
|
|
}
|
|
|
|
fn select_content_sources(
|
|
peer_game_db: &PeerGameDB,
|
|
game_id: &str,
|
|
content_id: ContentId,
|
|
quarantine: &ContentQuarantine,
|
|
) -> Vec<PeerEndpoint> {
|
|
let mut endpoints = peer_game_db.peer_endpoints_with_content(game_id, content_id);
|
|
endpoints.sort_unstable_by_key(|endpoint| (endpoint.peer_id, endpoint.addr));
|
|
endpoints.dedup();
|
|
|
|
endpoints
|
|
.into_iter()
|
|
.filter(|endpoint| !quarantine.is_quarantined(endpoint, content_id))
|
|
.collect()
|
|
}
|
|
|
|
fn load_catalog_manifest(ctx: &Ctx, game_id: &str) -> eyre::Result<Arc<CatalogContentManifest>> {
|
|
let catalog = Arc::clone(&ctx.catalog);
|
|
let game_id = game_id.to_owned();
|
|
scoped_blocking(move || catalog.manifest(&game_id))
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum StreamInstallFailureDisposition {
|
|
RetryAndQuarantine,
|
|
Retry,
|
|
Stop,
|
|
}
|
|
|
|
const fn stream_install_failure_disposition(
|
|
kind: StreamInstallReceiveErrorKind,
|
|
) -> StreamInstallFailureDisposition {
|
|
match kind {
|
|
StreamInstallReceiveErrorKind::Integrity => {
|
|
StreamInstallFailureDisposition::RetryAndQuarantine
|
|
}
|
|
StreamInstallReceiveErrorKind::Transport => StreamInstallFailureDisposition::Retry,
|
|
StreamInstallReceiveErrorKind::Cancelled | StreamInstallReceiveErrorKind::Setup => {
|
|
StreamInstallFailureDisposition::Stop
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct StreamDownloadError {
|
|
reason: Option<DownloadFailureReason>,
|
|
error: eyre::Report,
|
|
}
|
|
|
|
impl StreamDownloadError {
|
|
fn cancelled(error: impl Into<eyre::Report>) -> Self {
|
|
Self {
|
|
reason: None,
|
|
error: error.into(),
|
|
}
|
|
}
|
|
|
|
fn sources_exhausted(error: impl Into<eyre::Report>) -> Self {
|
|
Self {
|
|
reason: Some(DownloadFailureReason::VerifiedCatalogSourcesExhausted),
|
|
error: error.into(),
|
|
}
|
|
}
|
|
|
|
fn operation_failed(error: impl Into<eyre::Report>) -> Self {
|
|
Self {
|
|
reason: Some(DownloadFailureReason::OperationFailed),
|
|
error: error.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for StreamDownloadError {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
self.error.fmt(formatter)
|
|
}
|
|
}
|
|
|
|
/// Handles the `ListGames` command.
|
|
pub async fn handle_list_games_command(ctx: &Ctx, tx_notify_ui: &PeerEventSender) {
|
|
log::info!("ListGames command received");
|
|
events::emit_peer_game_list(&ctx.peer_game_db, tx_notify_ui).await;
|
|
}
|
|
|
|
/// Handles the `DownloadGameFiles` command.
|
|
#[allow(clippy::too_many_lines)]
|
|
pub async fn handle_download_game_files_command(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &PeerEventSender,
|
|
id: String,
|
|
install_after_download: bool,
|
|
) {
|
|
log::info!("Got PeerCommand::DownloadGameFiles");
|
|
let attempt = DownloadAttemptKey::next(id.clone());
|
|
if !catalog_contains(ctx, &id) {
|
|
log::warn!("Ignoring download command for non-catalog game {id}");
|
|
send_download_failed(
|
|
tx_notify_ui,
|
|
&attempt,
|
|
DownloadFailureReason::OperationFailed,
|
|
);
|
|
return;
|
|
}
|
|
|
|
let games_folder = { ctx.game_dir.read().await.clone() };
|
|
let target = OperationTarget::new(games_folder.clone(), id.clone());
|
|
let catalog_manifest = match load_catalog_manifest(ctx, &id) {
|
|
Ok(manifest) => manifest,
|
|
Err(error) => {
|
|
log::error!("Failed to load catalog content manifest for {id}: {error}");
|
|
send_download_failed(
|
|
tx_notify_ui,
|
|
&attempt,
|
|
DownloadFailureReason::OperationFailed,
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
let content_id = catalog_manifest.content_id();
|
|
let manifest_games_folder = games_folder.clone();
|
|
let manifest = scoped_blocking(move || {
|
|
ValidatedDownloadManifest::from_catalog(&manifest_games_folder, catalog_manifest)
|
|
});
|
|
let manifest = match manifest {
|
|
Ok(manifest) => manifest,
|
|
Err(error) => {
|
|
log::error!("Rejected catalog download manifest for {id}: {error}");
|
|
send_download_failed(
|
|
tx_notify_ui,
|
|
&attempt,
|
|
DownloadFailureReason::OperationFailed,
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let local_dl_available = {
|
|
if ctx.recovery_quarantine.is_blocked(&games_folder, &id) {
|
|
false
|
|
} else {
|
|
let active_operations = ctx.active_operations.read().await;
|
|
local_download_matches_catalog(
|
|
&games_folder,
|
|
ctx.state_dir.as_ref(),
|
|
&id,
|
|
&active_operations,
|
|
ctx.catalog.catalog(),
|
|
)
|
|
.await
|
|
&& download_ownership_matches_content(
|
|
&games_folder,
|
|
ctx.state_dir.as_ref(),
|
|
&id,
|
|
content_id,
|
|
)
|
|
.await
|
|
}
|
|
};
|
|
|
|
let network_permit = match ctx.network.try_acquire() {
|
|
Ok(permit) => permit,
|
|
Err(error) if local_dl_available => {
|
|
log::info!(
|
|
"Using locally downloaded files for game {id} while Local network sharing is unavailable: {error}"
|
|
);
|
|
finish_cached_download(ctx, tx_notify_ui, &attempt, install_after_download, target);
|
|
return;
|
|
}
|
|
Err(error) => {
|
|
log::warn!("Cannot download {id} while Local network sharing is unavailable: {error}");
|
|
send_download_failed(
|
|
tx_notify_ui,
|
|
&attempt,
|
|
DownloadFailureReason::OperationFailed,
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let sources = {
|
|
let peer_game_db = ctx.peer_game_db.read().await;
|
|
select_content_sources(&peer_game_db, &id, content_id, &ctx.content_quarantine)
|
|
};
|
|
|
|
if sources.is_empty() {
|
|
if local_dl_available {
|
|
finish_cached_download(ctx, tx_notify_ui, &attempt, install_after_download, target);
|
|
} else {
|
|
log::error!("No eligible exact catalog content source available for game {id}");
|
|
send_download_failed(
|
|
tx_notify_ui,
|
|
&attempt,
|
|
DownloadFailureReason::VerifiedCatalogSourcesExhausted,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
match begin_operation(ctx, tx_notify_ui, &target, 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,
|
|
&attempt,
|
|
DownloadFailureReason::OperationFailed,
|
|
);
|
|
return;
|
|
}
|
|
BeginOperationResult::PublicationFailed => {
|
|
log::error!("Failed to withdraw published availability before downloading {id}");
|
|
send_download_failed(
|
|
tx_notify_ui,
|
|
&attempt,
|
|
DownloadFailureReason::OperationFailed,
|
|
);
|
|
return;
|
|
}
|
|
BeginOperationResult::RecoveryBlocked => {
|
|
log::warn!("Ignoring download for recovery-blocked game {id}");
|
|
send_download_failed(
|
|
tx_notify_ui,
|
|
&attempt,
|
|
DownloadFailureReason::OperationFailed,
|
|
);
|
|
return;
|
|
}
|
|
BeginOperationResult::GameDirChanged => {
|
|
log::warn!(
|
|
"Game directory changed while preparing download for {id}; retry the request against the current library"
|
|
);
|
|
send_download_failed(
|
|
tx_notify_ui,
|
|
&attempt,
|
|
DownloadFailureReason::OperationFailed,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
let tx_notify_ui_clone = tx_notify_ui.clone();
|
|
let download_id = id.clone();
|
|
let cancel_token = network_permit.child_token();
|
|
let quic = network_permit.connector().clone();
|
|
let ctx_clone = ctx.clone();
|
|
let operation_target = target.clone();
|
|
let download_attempt =
|
|
register_download_attempt(ctx, tx_notify_ui, attempt, cancel_token.clone()).await;
|
|
|
|
ctx.task_tracker
|
|
.spawn(retain_guard_until_operation_completes(
|
|
network_permit,
|
|
async move {
|
|
let download_state_guard =
|
|
OperationGuard::download(download_id.clone(), cancel_token.clone());
|
|
|
|
let result = download_game_files(DownloadGameRequest {
|
|
attempt: &download_attempt,
|
|
manifest,
|
|
state_dir: ctx_clone.state_dir.as_ref(),
|
|
sources: &sources,
|
|
content_id,
|
|
quarantine: &ctx_clone.content_quarantine,
|
|
tx_notify_ui: tx_notify_ui_clone.clone(),
|
|
cancel_token: cancel_token.clone(),
|
|
quic,
|
|
})
|
|
.await;
|
|
download_attempt.close_source_admission();
|
|
download_attempt.clear_activity();
|
|
let result = match result {
|
|
Ok(completion) => {
|
|
settle_download_completion(&ctx_clone, &operation_target, completion)
|
|
.await
|
|
.map_err(|error| (error, Some(DownloadFailureReason::OperationFailed)))
|
|
}
|
|
Err(error) => {
|
|
let reason = error.reason();
|
|
Err((error.into_report(), reason))
|
|
}
|
|
};
|
|
|
|
match result {
|
|
Ok(()) => {
|
|
let Some(prepared) = prepare_install_operation(
|
|
&ctx_clone,
|
|
&tx_notify_ui_clone,
|
|
&operation_target,
|
|
)
|
|
.await
|
|
else {
|
|
finish_successful_download_after_refresh(
|
|
&ctx_clone,
|
|
&tx_notify_ui_clone,
|
|
&operation_target,
|
|
download_state_guard,
|
|
&download_attempt,
|
|
)
|
|
.await;
|
|
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_attempt.key()).await;
|
|
download_attempt.emit_finished();
|
|
run_started_install_operation(
|
|
&ctx_clone,
|
|
&tx_notify_ui_clone,
|
|
operation_target.clone(),
|
|
prepared,
|
|
download_state_guard,
|
|
cancel_token.clone(),
|
|
)
|
|
.await;
|
|
} else {
|
|
finish_successful_download_after_refresh(
|
|
&ctx_clone,
|
|
&tx_notify_ui_clone,
|
|
&operation_target,
|
|
download_state_guard,
|
|
&download_attempt,
|
|
)
|
|
.await;
|
|
}
|
|
} else {
|
|
finish_successful_download_after_refresh(
|
|
&ctx_clone,
|
|
&tx_notify_ui_clone,
|
|
&operation_target,
|
|
download_state_guard,
|
|
&download_attempt,
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
Err((e, reason)) => {
|
|
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}");
|
|
}
|
|
finish_failed_download_after_refresh(
|
|
&ctx_clone,
|
|
&tx_notify_ui_clone,
|
|
&operation_target,
|
|
download_state_guard,
|
|
&download_attempt,
|
|
reason,
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
},
|
|
));
|
|
}
|
|
|
|
fn finish_cached_download(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &PeerEventSender,
|
|
attempt: &DownloadAttemptKey,
|
|
install_after_download: bool,
|
|
target: OperationTarget,
|
|
) {
|
|
let id = &attempt.id;
|
|
log::info!("Using locally downloaded files for game {id}; skipping peer transfer");
|
|
let status = DownloadAttemptStatus::new(
|
|
attempt.clone(),
|
|
CancellationToken::new(),
|
|
tx_notify_ui.clone(),
|
|
);
|
|
status.emit_begin();
|
|
status.close_source_admission();
|
|
status.emit_finished();
|
|
if install_after_download {
|
|
spawn_install_operation(ctx, tx_notify_ui, target);
|
|
}
|
|
}
|
|
|
|
async fn settle_download_completion(
|
|
ctx: &Ctx,
|
|
target: &OperationTarget,
|
|
completion: DownloadCompletion,
|
|
) -> eyre::Result<()> {
|
|
let game_id = target.game_id();
|
|
let uncertain_commit = match completion {
|
|
DownloadCompletion::Durable => None,
|
|
DownloadCompletion::RecoveryRequired(cause) => {
|
|
log::warn!(
|
|
"Download committed for {game_id}, but readiness is quarantined pending recovery: {cause}"
|
|
);
|
|
Some(cause)
|
|
}
|
|
};
|
|
|
|
install::recover_game_root(&target.game_root(), ctx.state_dir.as_ref(), game_id)
|
|
.await
|
|
.map_err(|recovery_error| match uncertain_commit {
|
|
Some(cause) => recovery_error.wrap_err(format!(
|
|
"download recovery did not settle for {game_id}; original completion error: {cause}"
|
|
)),
|
|
None => recovery_error.wrap_err(format!(
|
|
"download completion recovery did not settle for {game_id}"
|
|
)),
|
|
})?;
|
|
log::info!("Settled committed download state for {game_id} before publication");
|
|
Ok(())
|
|
}
|
|
|
|
/// Handles the `InstallGame` command.
|
|
pub async fn handle_install_game_command(ctx: &Ctx, tx_notify_ui: &PeerEventSender, id: String) {
|
|
let games_folder = ctx.game_dir.read().await.clone();
|
|
spawn_install_operation(ctx, tx_notify_ui, OperationTarget::new(games_folder, id));
|
|
}
|
|
|
|
async fn stream_install_target_is_ready(ctx: &Ctx, target: &OperationTarget) -> bool {
|
|
let id = target.game_id();
|
|
if ctx.recovery_quarantine.is_blocked(&target.games_folder, id) {
|
|
log::warn!("Ignoring streamed install command for recovery-blocked game {id}");
|
|
return false;
|
|
}
|
|
if download_ownership_readiness(&target.games_folder, ctx.state_dir.as_ref(), id).await
|
|
== DownloadOwnershipReadiness::RecoveryRequired
|
|
{
|
|
log::warn!("Ignoring streamed install command for recovery-quarantined game {id}");
|
|
return false;
|
|
}
|
|
if local_dir_is_directory(&target.game_root()).await {
|
|
log::warn!("Ignoring streamed install command for already-installed game {id}");
|
|
return false;
|
|
}
|
|
true
|
|
}
|
|
|
|
async fn begin_stream_install_operation(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &PeerEventSender,
|
|
target: &OperationTarget,
|
|
attempt: &DownloadAttemptKey,
|
|
) -> bool {
|
|
let id = target.game_id();
|
|
match begin_operation(ctx, tx_notify_ui, target, OperationKind::Downloading).await {
|
|
BeginOperationResult::Started => true,
|
|
BeginOperationResult::AlreadyActive => {
|
|
log::warn!("Operation for {id} already in progress; ignoring streamed install request");
|
|
false
|
|
}
|
|
BeginOperationResult::DrainTimedOut => {
|
|
log::error!("Timed out waiting for outbound transfers before streamed install of {id}");
|
|
send_download_failed(
|
|
tx_notify_ui,
|
|
attempt,
|
|
DownloadFailureReason::OperationFailed,
|
|
);
|
|
false
|
|
}
|
|
BeginOperationResult::PublicationFailed => {
|
|
log::error!(
|
|
"Failed to withdraw published availability before streamed install of {id}"
|
|
);
|
|
send_download_failed(
|
|
tx_notify_ui,
|
|
attempt,
|
|
DownloadFailureReason::OperationFailed,
|
|
);
|
|
false
|
|
}
|
|
BeginOperationResult::RecoveryBlocked => {
|
|
log::warn!("Ignoring streamed install for recovery-blocked game {id}");
|
|
send_download_failed(
|
|
tx_notify_ui,
|
|
attempt,
|
|
DownloadFailureReason::OperationFailed,
|
|
);
|
|
false
|
|
}
|
|
BeginOperationResult::GameDirChanged => {
|
|
log::warn!(
|
|
"Game directory changed while preparing streamed install for {id}; retry the request against the current library"
|
|
);
|
|
send_download_failed(
|
|
tx_notify_ui,
|
|
attempt,
|
|
DownloadFailureReason::OperationFailed,
|
|
);
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn handle_stream_install_game_command(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &PeerEventSender,
|
|
id: String,
|
|
settings: StreamInstallSettings,
|
|
) {
|
|
let attempt = DownloadAttemptKey::next(id.clone());
|
|
if !catalog_contains(ctx, &id) {
|
|
log::warn!("Ignoring streamed install command for non-catalog game {id}");
|
|
send_download_failed(
|
|
tx_notify_ui,
|
|
&attempt,
|
|
DownloadFailureReason::OperationFailed,
|
|
);
|
|
return;
|
|
}
|
|
|
|
let manifest = match load_catalog_manifest(ctx, &id) {
|
|
Ok(manifest) => manifest,
|
|
Err(error) => {
|
|
log::error!("Failed to load catalog content manifest for {id}: {error}");
|
|
send_download_failed(
|
|
tx_notify_ui,
|
|
&attempt,
|
|
DownloadFailureReason::OperationFailed,
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
if !manifest.supports_streamed_install() {
|
|
log::warn!("Ignoring streamed install for {id}: catalog has no extracted-file manifest");
|
|
send_download_failed(
|
|
tx_notify_ui,
|
|
&attempt,
|
|
DownloadFailureReason::OperationFailed,
|
|
);
|
|
return;
|
|
}
|
|
|
|
let games_folder = { ctx.game_dir.read().await.clone() };
|
|
let target = OperationTarget::new(games_folder, id.clone());
|
|
if !stream_install_target_is_ready(ctx, &target).await {
|
|
send_download_failed(
|
|
tx_notify_ui,
|
|
&attempt,
|
|
DownloadFailureReason::OperationFailed,
|
|
);
|
|
return;
|
|
}
|
|
|
|
let network_permit = match ctx.network.try_acquire() {
|
|
Ok(permit) => permit,
|
|
Err(error) => {
|
|
log::warn!(
|
|
"Cannot stream install {id} while Local network sharing is unavailable: {error}"
|
|
);
|
|
send_download_failed(
|
|
tx_notify_ui,
|
|
&attempt,
|
|
DownloadFailureReason::OperationFailed,
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
if !begin_stream_install_operation(ctx, tx_notify_ui, &target, &attempt).await {
|
|
return;
|
|
}
|
|
|
|
let cancel_token = network_permit.child_token();
|
|
let quic = network_permit.connector().clone();
|
|
let download_attempt =
|
|
register_download_attempt(ctx, tx_notify_ui, attempt, cancel_token.clone()).await;
|
|
let download_guard = OperationGuard::download(id.clone(), cancel_token.clone());
|
|
|
|
// Root-dependent preflight is repeated after admission. A preceding
|
|
// same-game operation may have changed ownership or install state after the
|
|
// optimistic check above but before this operation acquired the gate.
|
|
if !stream_install_target_is_ready(ctx, &target).await {
|
|
finish_failed_stream_download(
|
|
ctx,
|
|
tx_notify_ui,
|
|
&target,
|
|
download_guard,
|
|
&download_attempt,
|
|
Some(DownloadFailureReason::OperationFailed),
|
|
)
|
|
.await;
|
|
return;
|
|
}
|
|
|
|
let ctx_clone = ctx.clone();
|
|
let tx_notify_ui = tx_notify_ui.clone();
|
|
let operation = StreamInstallOperation {
|
|
ctx: ctx_clone,
|
|
tx_notify_ui,
|
|
target,
|
|
manifest,
|
|
settings,
|
|
quic,
|
|
cancel_token,
|
|
download_guard,
|
|
download_attempt,
|
|
};
|
|
ctx.task_tracker
|
|
.spawn(retain_guard_until_operation_completes(
|
|
network_permit,
|
|
run_stream_install_operation(operation),
|
|
));
|
|
}
|
|
|
|
struct StreamInstallOperation {
|
|
ctx: Ctx,
|
|
tx_notify_ui: PeerEventSender,
|
|
target: OperationTarget,
|
|
manifest: Arc<CatalogContentManifest>,
|
|
settings: StreamInstallSettings,
|
|
quic: QuicConnector,
|
|
cancel_token: CancellationToken,
|
|
download_guard: OperationGuard,
|
|
download_attempt: DownloadAttemptStatus,
|
|
}
|
|
|
|
async fn stream_install_sources(
|
|
ctx: &Ctx,
|
|
target: &OperationTarget,
|
|
manifest: &CatalogContentManifest,
|
|
) -> Vec<PeerEndpoint> {
|
|
let peer_game_db = ctx.peer_game_db.read().await;
|
|
select_content_sources(
|
|
&peer_game_db,
|
|
target.game_id(),
|
|
manifest.content_id(),
|
|
&ctx.content_quarantine,
|
|
)
|
|
}
|
|
|
|
async fn select_stream_install_sources_or_finish(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &PeerEventSender,
|
|
target: &OperationTarget,
|
|
manifest: &CatalogContentManifest,
|
|
download_guard: OperationGuard,
|
|
download_attempt: &DownloadAttemptStatus,
|
|
) -> Option<(Vec<PeerEndpoint>, OperationGuard)> {
|
|
let sources = stream_install_sources(ctx, target, manifest).await;
|
|
if !sources.is_empty() {
|
|
return Some((sources, download_guard));
|
|
}
|
|
|
|
log::error!(
|
|
"No eligible exact catalog content source available for streamed install of {}",
|
|
target.game_id()
|
|
);
|
|
finish_failed_stream_download(
|
|
ctx,
|
|
tx_notify_ui,
|
|
target,
|
|
download_guard,
|
|
download_attempt,
|
|
Some(DownloadFailureReason::VerifiedCatalogSourcesExhausted),
|
|
)
|
|
.await;
|
|
None
|
|
}
|
|
|
|
/// Handles the `UninstallGame` command.
|
|
pub async fn handle_uninstall_game_command(ctx: &Ctx, tx_notify_ui: &PeerEventSender, id: String) {
|
|
let games_folder = ctx.game_dir.read().await.clone();
|
|
let target = OperationTarget::new(games_folder, id);
|
|
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, target).await;
|
|
});
|
|
}
|
|
|
|
pub async fn handle_remove_downloaded_game_command(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &PeerEventSender,
|
|
id: String,
|
|
) {
|
|
let games_folder = ctx.game_dir.read().await.clone();
|
|
let target = OperationTarget::new(games_folder, id);
|
|
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, target).await;
|
|
});
|
|
}
|
|
|
|
pub async fn handle_cancel_download_command(
|
|
ctx: &Ctx,
|
|
_tx_notify_ui: &PeerEventSender,
|
|
id: String,
|
|
) {
|
|
let signal = ctx.active_downloads.read().await.get(&id).cloned();
|
|
let Some(signal) = signal else {
|
|
log::warn!("Ignoring cancel request for inactive download {id}");
|
|
return;
|
|
};
|
|
|
|
log::info!("Cancelling download for game {id}");
|
|
signal.cancel_silently();
|
|
}
|
|
|
|
async fn run_stream_install_operation(operation: StreamInstallOperation) {
|
|
let StreamInstallOperation {
|
|
ctx,
|
|
tx_notify_ui,
|
|
target,
|
|
manifest,
|
|
settings,
|
|
quic,
|
|
cancel_token,
|
|
download_guard,
|
|
download_attempt,
|
|
} = operation;
|
|
let id = target.game_id.clone();
|
|
download_attempt.emit_begin();
|
|
|
|
let Some((sources, download_guard)) = select_stream_install_sources_or_finish(
|
|
&ctx,
|
|
&tx_notify_ui,
|
|
&target,
|
|
&manifest,
|
|
download_guard,
|
|
&download_attempt,
|
|
)
|
|
.await
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let receive_result = receive_streamed_install_from_peers(StreamInstallReceiveRequest {
|
|
ctx: &ctx,
|
|
tx_notify_ui: &tx_notify_ui,
|
|
target: &target,
|
|
manifest: &manifest,
|
|
sources: &sources,
|
|
quic: &quic,
|
|
cancel_token: &cancel_token,
|
|
attempt: download_attempt.reporter(),
|
|
})
|
|
.await;
|
|
download_attempt.close_source_admission();
|
|
download_attempt.clear_activity();
|
|
|
|
match receive_result {
|
|
Ok(transaction) => {
|
|
let promotion = StreamInstallPromotionPreparation {
|
|
ctx: &ctx,
|
|
tx_notify_ui: &tx_notify_ui,
|
|
target: &target,
|
|
transaction,
|
|
settings: &settings,
|
|
cancel_token: &cancel_token,
|
|
download_guard,
|
|
download_attempt: &download_attempt,
|
|
};
|
|
let Some((transaction, download_guard)) =
|
|
prepare_streamed_install_for_promotion(promotion).await
|
|
else {
|
|
return;
|
|
};
|
|
|
|
if transition_download_to_install(&ctx, &tx_notify_ui, &id, OperationKind::Installing)
|
|
.await
|
|
{
|
|
commit_streamed_install(StreamInstallCommit {
|
|
ctx,
|
|
tx_notify_ui,
|
|
target,
|
|
transaction,
|
|
manifest,
|
|
cancel_token,
|
|
operation_guard: download_guard,
|
|
download_attempt,
|
|
})
|
|
.await;
|
|
return;
|
|
}
|
|
|
|
if let Err(err) = transaction.rollback() {
|
|
log::error!("Failed to roll back streamed install for {id}: {err}");
|
|
}
|
|
finish_failed_stream_download(
|
|
&ctx,
|
|
&tx_notify_ui,
|
|
&target,
|
|
download_guard,
|
|
&download_attempt,
|
|
Some(DownloadFailureReason::OperationFailed),
|
|
)
|
|
.await;
|
|
}
|
|
Err(err) => {
|
|
finish_stream_receive_error(
|
|
&ctx,
|
|
&tx_notify_ui,
|
|
&target,
|
|
download_guard,
|
|
&download_attempt,
|
|
err,
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn finish_stream_receive_error(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &PeerEventSender,
|
|
target: &OperationTarget,
|
|
download_guard: OperationGuard,
|
|
download_attempt: &DownloadAttemptStatus,
|
|
error: StreamDownloadError,
|
|
) {
|
|
if error.reason.is_none() {
|
|
log::info!(
|
|
"Streamed install download cancelled for {}: {error}",
|
|
target.game_id()
|
|
);
|
|
} else {
|
|
log::error!(
|
|
"Streamed install download failed for {}: {error}",
|
|
target.game_id()
|
|
);
|
|
}
|
|
finish_failed_stream_download(
|
|
ctx,
|
|
tx_notify_ui,
|
|
target,
|
|
download_guard,
|
|
download_attempt,
|
|
error.reason,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
struct StreamInstallPromotionPreparation<'a> {
|
|
ctx: &'a Ctx,
|
|
tx_notify_ui: &'a PeerEventSender,
|
|
target: &'a OperationTarget,
|
|
transaction: install::StreamedInstallTransaction,
|
|
settings: &'a StreamInstallSettings,
|
|
cancel_token: &'a CancellationToken,
|
|
download_guard: OperationGuard,
|
|
download_attempt: &'a DownloadAttemptStatus,
|
|
}
|
|
|
|
async fn prepare_streamed_install_for_promotion(
|
|
preparation: StreamInstallPromotionPreparation<'_>,
|
|
) -> Option<(install::StreamedInstallTransaction, OperationGuard)> {
|
|
let StreamInstallPromotionPreparation {
|
|
ctx,
|
|
tx_notify_ui,
|
|
target,
|
|
transaction,
|
|
settings,
|
|
cancel_token,
|
|
download_guard,
|
|
download_attempt,
|
|
} = preparation;
|
|
let id = target.game_id();
|
|
if let Err(error) = apply_launch_settings_to_verified_tree(
|
|
transaction.staging_dir(),
|
|
Some(settings.account_name()),
|
|
Some(settings.language()),
|
|
Some(settings.persona_name()),
|
|
) {
|
|
log::error!(
|
|
"Failed to apply launch settings to verified streamed install for {id}: {error}"
|
|
);
|
|
if let Err(rollback_error) = transaction.rollback() {
|
|
log::error!(
|
|
"Failed to roll back streamed install for {id} after launch-settings failure: {rollback_error}"
|
|
);
|
|
}
|
|
finish_failed_stream_download(
|
|
ctx,
|
|
tx_notify_ui,
|
|
target,
|
|
download_guard,
|
|
download_attempt,
|
|
Some(DownloadFailureReason::OperationFailed),
|
|
)
|
|
.await;
|
|
return None;
|
|
}
|
|
|
|
// Launch-settings mutation is intentionally finite and non-detachable.
|
|
// Honor cancellation after it reaches a stable tree boundary, before the
|
|
// verified staging directory can be promoted.
|
|
if cancel_token.is_cancelled() {
|
|
log::info!("Streamed install for {id} was cancelled after applying launch settings");
|
|
let reason = match transaction.rollback() {
|
|
Ok(()) => None,
|
|
Err(rollback_error) => {
|
|
log::error!(
|
|
"Failed to roll back cancelled streamed install for {id}: {rollback_error}"
|
|
);
|
|
Some(DownloadFailureReason::OperationFailed)
|
|
}
|
|
};
|
|
finish_failed_stream_download(
|
|
ctx,
|
|
tx_notify_ui,
|
|
target,
|
|
download_guard,
|
|
download_attempt,
|
|
reason,
|
|
)
|
|
.await;
|
|
return None;
|
|
}
|
|
|
|
Some((transaction, download_guard))
|
|
}
|
|
|
|
struct StreamInstallReceiveRequest<'a> {
|
|
ctx: &'a Ctx,
|
|
tx_notify_ui: &'a PeerEventSender,
|
|
target: &'a OperationTarget,
|
|
manifest: &'a Arc<CatalogContentManifest>,
|
|
sources: &'a [PeerEndpoint],
|
|
quic: &'a QuicConnector,
|
|
cancel_token: &'a CancellationToken,
|
|
attempt: DownloadAttemptReporter,
|
|
}
|
|
|
|
fn settle_failed_stream_receive(
|
|
ctx: &Ctx,
|
|
source: &PeerEndpoint,
|
|
content_id: ContentId,
|
|
id: &str,
|
|
transaction: install::StreamedInstallTransaction,
|
|
error: StreamInstallReceiveError,
|
|
) -> Result<(StreamInstallFailureDisposition, StreamInstallReceiveError), StreamDownloadError> {
|
|
let disposition = stream_install_failure_disposition(error.kind());
|
|
if disposition == StreamInstallFailureDisposition::RetryAndQuarantine {
|
|
ctx.content_quarantine
|
|
.record_integrity_failure(source, content_id);
|
|
}
|
|
transaction.rollback().map_err(|rollback_error| {
|
|
StreamDownloadError::operation_failed(eyre::eyre!(
|
|
"streamed install attempt from {} at {} failed for {id}: {error}; rollback also failed: {rollback_error}",
|
|
source.peer_id,
|
|
source.addr
|
|
))
|
|
})?;
|
|
Ok((disposition, error))
|
|
}
|
|
|
|
fn exhausted_stream_receive_error(
|
|
id: &str,
|
|
last_receive_error: Option<StreamInstallReceiveError>,
|
|
) -> StreamDownloadError {
|
|
match last_receive_error {
|
|
Some(error) => StreamDownloadError::sources_exhausted(eyre::Report::new(error)),
|
|
None => StreamDownloadError::sources_exhausted(eyre::eyre!(
|
|
"streamed install download failed for {id}: no peer attempts were made"
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn begin_stream_receive_attempt(
|
|
game_root: &Path,
|
|
state_dir: &Path,
|
|
id: &str,
|
|
retry_invalid_source: &mut bool,
|
|
attempt: &DownloadAttemptReporter,
|
|
) -> Result<install::StreamedInstallTransaction, StreamDownloadError> {
|
|
let transaction = install::begin_streamed_install(game_root, state_dir, id)
|
|
.map_err(StreamDownloadError::operation_failed)?;
|
|
if std::mem::take(retry_invalid_source) {
|
|
attempt.set_activity(DownloadVerificationActivity::RetryingInvalidSource);
|
|
} else {
|
|
attempt.set_activity(DownloadVerificationActivity::VerifyingDownloadedChunks);
|
|
}
|
|
Ok(transaction)
|
|
}
|
|
|
|
async fn receive_streamed_install_from_peers(
|
|
request: StreamInstallReceiveRequest<'_>,
|
|
) -> Result<install::StreamedInstallTransaction, StreamDownloadError> {
|
|
let StreamInstallReceiveRequest {
|
|
ctx,
|
|
tx_notify_ui,
|
|
target,
|
|
manifest,
|
|
sources,
|
|
quic,
|
|
cancel_token,
|
|
attempt,
|
|
} = request;
|
|
let id = target.game_id();
|
|
let game_root = target.game_root();
|
|
let mut last_receive_error = None;
|
|
let mut retry_invalid_source = false;
|
|
let content_id = manifest.content_id();
|
|
for source in sources {
|
|
if cancel_token.is_cancelled() {
|
|
return Err(StreamDownloadError::cancelled(eyre::eyre!(
|
|
"streamed install for {id} was cancelled"
|
|
)));
|
|
}
|
|
if ctx.content_quarantine.is_quarantined(source, content_id) {
|
|
log::debug!(
|
|
"Skipping quarantined streamed-install source {} at {} for {id}",
|
|
source.peer_id,
|
|
source.addr
|
|
);
|
|
continue;
|
|
}
|
|
|
|
let transaction = begin_stream_receive_attempt(
|
|
&game_root,
|
|
ctx.state_dir.as_ref(),
|
|
id,
|
|
&mut retry_invalid_source,
|
|
&attempt,
|
|
)?;
|
|
let receive_result = receive_streamed_install(ReceiveStreamedInstallRequest {
|
|
endpoint: *source,
|
|
game_id: id,
|
|
manifest: Arc::clone(manifest),
|
|
staging_dir: transaction.staging_dir(),
|
|
attempt: attempt.clone(),
|
|
tx_notify_ui: tx_notify_ui.clone(),
|
|
quic,
|
|
cancel_token: cancel_token.clone(),
|
|
})
|
|
.await;
|
|
|
|
match receive_result {
|
|
Ok(()) => return Ok(transaction),
|
|
Err(err) => {
|
|
let error_kind = err.kind();
|
|
let (disposition, err) =
|
|
settle_failed_stream_receive(ctx, source, content_id, id, transaction, err)?;
|
|
|
|
match disposition {
|
|
StreamInstallFailureDisposition::RetryAndQuarantine => {
|
|
log::warn!(
|
|
"Streamed install attempt from {} at {} failed for {id}; trying another peer if available: {err}",
|
|
source.peer_id,
|
|
source.addr
|
|
);
|
|
last_receive_error = Some(err);
|
|
retry_invalid_source = true;
|
|
}
|
|
StreamInstallFailureDisposition::Retry => {
|
|
log::warn!(
|
|
"Streamed install attempt from {} at {} failed for {id}; trying another peer if available: {err}",
|
|
source.peer_id,
|
|
source.addr
|
|
);
|
|
last_receive_error = Some(err);
|
|
}
|
|
StreamInstallFailureDisposition::Stop => {
|
|
let error = eyre::Report::new(err);
|
|
return Err(match error_kind {
|
|
StreamInstallReceiveErrorKind::Cancelled => {
|
|
StreamDownloadError::cancelled(error)
|
|
}
|
|
StreamInstallReceiveErrorKind::Setup => {
|
|
StreamDownloadError::operation_failed(error)
|
|
}
|
|
StreamInstallReceiveErrorKind::Integrity
|
|
| StreamInstallReceiveErrorKind::Transport => {
|
|
unreachable!("retryable stream failures cannot stop immediately")
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Err(exhausted_stream_receive_error(id, last_receive_error))
|
|
}
|
|
|
|
async fn finish_failed_stream_download(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &PeerEventSender,
|
|
target: &OperationTarget,
|
|
guard: OperationGuard,
|
|
status: &DownloadAttemptStatus,
|
|
direct_reason: Option<DownloadFailureReason>,
|
|
) {
|
|
status.close_source_admission();
|
|
status.clear_activity();
|
|
if settle_and_end_download(
|
|
ctx,
|
|
tx_notify_ui,
|
|
target,
|
|
guard,
|
|
status.key(),
|
|
"streamed install failure",
|
|
)
|
|
.await
|
|
&& let Some(reason) = status.resolve_failure(direct_reason)
|
|
{
|
|
status.emit_failed(reason);
|
|
}
|
|
}
|
|
|
|
struct StreamInstallCommit {
|
|
ctx: Ctx,
|
|
tx_notify_ui: PeerEventSender,
|
|
target: OperationTarget,
|
|
transaction: install::StreamedInstallTransaction,
|
|
manifest: Arc<CatalogContentManifest>,
|
|
cancel_token: CancellationToken,
|
|
operation_guard: OperationGuard,
|
|
download_attempt: DownloadAttemptStatus,
|
|
}
|
|
|
|
async fn commit_streamed_install(commit: StreamInstallCommit) {
|
|
let StreamInstallCommit {
|
|
ctx,
|
|
tx_notify_ui,
|
|
target,
|
|
transaction,
|
|
manifest,
|
|
cancel_token,
|
|
operation_guard,
|
|
download_attempt,
|
|
} = commit;
|
|
let id = target.game_id().to_owned();
|
|
|
|
match promote_streamed_install(
|
|
ctx.state_dir.as_ref(),
|
|
&id,
|
|
transaction,
|
|
&manifest,
|
|
&cancel_token,
|
|
) {
|
|
Ok(()) => {
|
|
// Promotion is now past its durable rename boundary. Only publish
|
|
// download success after cancellation can no longer veto it.
|
|
clear_active_download(&ctx, download_attempt.key()).await;
|
|
download_attempt.emit_finished();
|
|
if settle_and_end_operation(
|
|
&ctx,
|
|
&tx_notify_ui,
|
|
&target,
|
|
operation_guard,
|
|
"streamed install completion",
|
|
)
|
|
.await
|
|
{
|
|
events::send(
|
|
&tx_notify_ui,
|
|
PeerEvent::InstallGameFinished { id: id.clone() },
|
|
);
|
|
} else {
|
|
events::send(
|
|
&tx_notify_ui,
|
|
PeerEvent::InstallGameFailed { id: id.clone() },
|
|
);
|
|
}
|
|
}
|
|
Err(error) => {
|
|
let direct_reason = match error {
|
|
install::StreamedInstallCommitError::Cancelled(error) => {
|
|
log::info!("Streamed install was cancelled before promotion for {id}: {error}");
|
|
None
|
|
}
|
|
install::StreamedInstallCommitError::OperationFailed(error) => {
|
|
log::error!("Streamed install commit failed for {id}: {error}");
|
|
Some(DownloadFailureReason::OperationFailed)
|
|
}
|
|
};
|
|
let settled = settle_and_end_operation(
|
|
&ctx,
|
|
&tx_notify_ui,
|
|
&target,
|
|
operation_guard,
|
|
"streamed install commit failure",
|
|
)
|
|
.await;
|
|
clear_active_download(&ctx, download_attempt.key()).await;
|
|
if settled && let Some(reason) = download_attempt.resolve_failure(direct_reason) {
|
|
download_attempt.emit_failed(reason);
|
|
events::send(
|
|
&tx_notify_ui,
|
|
PeerEvent::InstallGameFailed { id: id.clone() },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn promote_streamed_install(
|
|
state_dir: &Path,
|
|
id: &str,
|
|
transaction: install::StreamedInstallTransaction,
|
|
manifest: &CatalogContentManifest,
|
|
cancel_token: &CancellationToken,
|
|
) -> Result<(), install::StreamedInstallCommitError> {
|
|
transaction.commit_classified(manifest, cancel_token)?;
|
|
if let Err(error) = mark_launch_settings_applied(state_dir, id) {
|
|
log::warn!(
|
|
"Streamed install for {id} was promoted, but its launch-settings marker could not be written; first play will retry the rewrite: {error}"
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn spawn_install_operation(ctx: &Ctx, tx_notify_ui: &PeerEventSender, target: OperationTarget) {
|
|
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, target).await;
|
|
});
|
|
}
|
|
|
|
async fn run_install_operation(ctx: &Ctx, tx_notify_ui: &PeerEventSender, target: OperationTarget) {
|
|
let id = target.game_id().to_owned();
|
|
let Some(prepared) = prepare_install_operation(ctx, tx_notify_ui, &target).await else {
|
|
return;
|
|
};
|
|
|
|
match begin_operation(ctx, tx_notify_ui, &target, 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;
|
|
}
|
|
BeginOperationResult::PublicationFailed => {
|
|
log::error!("Failed to withdraw published availability before install/update of {id}");
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::InstallGameFailed { id: id.clone() },
|
|
);
|
|
return;
|
|
}
|
|
BeginOperationResult::RecoveryBlocked => {
|
|
log::warn!("Ignoring install for recovery-blocked game {id}");
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::InstallGameFailed { id: id.clone() },
|
|
);
|
|
return;
|
|
}
|
|
BeginOperationResult::GameDirChanged => {
|
|
log::warn!(
|
|
"Game directory changed while preparing install for {id}; retry the request against the current library"
|
|
);
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::InstallGameFailed { id: id.clone() },
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
let cancel_token = ctx.shutdown.child_token();
|
|
let operation_guard = OperationGuard::cancellable(id.clone(), cancel_token.clone());
|
|
let Some(revalidated) =
|
|
revalidate_install_operation(ctx, tx_notify_ui, &target, prepared.operation_kind).await
|
|
else {
|
|
let _ = settle_and_end_operation(
|
|
ctx,
|
|
tx_notify_ui,
|
|
&target,
|
|
operation_guard,
|
|
"install preflight rejection",
|
|
)
|
|
.await;
|
|
return;
|
|
};
|
|
|
|
run_started_install_operation(
|
|
ctx,
|
|
tx_notify_ui,
|
|
target,
|
|
revalidated,
|
|
operation_guard,
|
|
cancel_token,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
struct PreparedInstallOperation {
|
|
operation: InstallOperation,
|
|
operation_kind: OperationKind,
|
|
}
|
|
|
|
async fn prepare_install_operation(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &PeerEventSender,
|
|
target: &OperationTarget,
|
|
) -> Option<PreparedInstallOperation> {
|
|
let id = target.game_id();
|
|
if !catalog_contains(ctx, id) {
|
|
log::warn!("Ignoring install command for non-catalog game {id}");
|
|
return None;
|
|
}
|
|
|
|
if ctx.recovery_quarantine.is_blocked(&target.games_folder, id) {
|
|
log::warn!("Ignoring install command for recovery-blocked game {id}");
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::InstallGameFailed { id: id.to_string() },
|
|
);
|
|
return None;
|
|
}
|
|
if download_ownership_readiness(&target.games_folder, ctx.state_dir.as_ref(), id).await
|
|
== DownloadOwnershipReadiness::RecoveryRequired
|
|
{
|
|
log::warn!("Ignoring install command for recovery-quarantined game {id}");
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::InstallGameFailed { id: id.to_string() },
|
|
);
|
|
return None;
|
|
}
|
|
|
|
let game_root = target.game_root();
|
|
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 {
|
|
operation,
|
|
operation_kind,
|
|
})
|
|
}
|
|
|
|
async fn revalidate_install_operation(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &PeerEventSender,
|
|
target: &OperationTarget,
|
|
expected_kind: OperationKind,
|
|
) -> Option<PreparedInstallOperation> {
|
|
let revalidated = prepare_install_operation(ctx, tx_notify_ui, target).await?;
|
|
if revalidated.operation_kind == expected_kind {
|
|
return Some(revalidated);
|
|
}
|
|
|
|
let id = target.game_id();
|
|
log::warn!(
|
|
"Install state changed while admitting {id}; refusing stale {expected_kind:?} operation as {:?}",
|
|
revalidated.operation_kind
|
|
);
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::InstallGameFailed { id: id.to_string() },
|
|
);
|
|
None
|
|
}
|
|
|
|
async fn run_started_install_operation(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &PeerEventSender,
|
|
target: OperationTarget,
|
|
prepared: PreparedInstallOperation,
|
|
operation_guard: OperationGuard,
|
|
cancel_token: CancellationToken,
|
|
) {
|
|
let id = target.game_id().to_owned();
|
|
let game_root = target.game_root();
|
|
let operation = prepared.operation;
|
|
let result = {
|
|
let state_dir = ctx.state_dir.as_ref();
|
|
match operation {
|
|
InstallOperation::Installing => {
|
|
install::install(
|
|
&game_root,
|
|
state_dir,
|
|
&id,
|
|
ctx.unpacker.clone(),
|
|
cancel_token.clone(),
|
|
)
|
|
.await
|
|
}
|
|
InstallOperation::Updating => {
|
|
install::update(
|
|
&game_root,
|
|
state_dir,
|
|
&id,
|
|
ctx.unpacker.clone(),
|
|
cancel_token.clone(),
|
|
)
|
|
.await
|
|
}
|
|
}
|
|
};
|
|
|
|
match result {
|
|
Ok(()) => {
|
|
if settle_and_end_operation(
|
|
ctx,
|
|
tx_notify_ui,
|
|
&target,
|
|
operation_guard,
|
|
"install completion",
|
|
)
|
|
.await
|
|
{
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::InstallGameFinished { id: id.clone() },
|
|
);
|
|
} else {
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::InstallGameFailed { id: id.clone() },
|
|
);
|
|
}
|
|
}
|
|
Err(err) => {
|
|
log::error!("Install operation failed for {id}: {err}");
|
|
let _ = settle_and_end_operation(
|
|
ctx,
|
|
tx_notify_ui,
|
|
&target,
|
|
operation_guard,
|
|
"install failure",
|
|
)
|
|
.await;
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::InstallGameFailed { id: id.clone() },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn run_uninstall_operation(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &PeerEventSender,
|
|
target: OperationTarget,
|
|
) {
|
|
let id = target.game_id().to_owned();
|
|
if !catalog_contains(ctx, &id) {
|
|
log::warn!("Ignoring uninstall command for non-catalog game {id}");
|
|
return;
|
|
}
|
|
|
|
match begin_operation(ctx, tx_notify_ui, &target, 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;
|
|
}
|
|
BeginOperationResult::PublicationFailed => {
|
|
log::error!("Failed to withdraw published availability before uninstall of {id}");
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::UninstallGameFailed { id: id.clone() },
|
|
);
|
|
return;
|
|
}
|
|
BeginOperationResult::RecoveryBlocked => {
|
|
log::warn!("Ignoring uninstall for recovery-blocked game {id}");
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::UninstallGameFailed { id: id.clone() },
|
|
);
|
|
return;
|
|
}
|
|
BeginOperationResult::GameDirChanged => {
|
|
log::warn!(
|
|
"Game directory changed before uninstall admission for {id}; refusing to retarget the command"
|
|
);
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::UninstallGameFailed { id: id.clone() },
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
let game_root = target.game_root();
|
|
let operation_guard = OperationGuard::new(id.clone());
|
|
let result = install::uninstall(&game_root, ctx.state_dir.as_ref(), &id);
|
|
|
|
match result {
|
|
Ok(()) => {
|
|
if settle_and_end_operation(
|
|
ctx,
|
|
tx_notify_ui,
|
|
&target,
|
|
operation_guard,
|
|
"uninstall completion",
|
|
)
|
|
.await
|
|
{
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::UninstallGameFinished { id: id.clone() },
|
|
);
|
|
} else {
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::UninstallGameFailed { id: id.clone() },
|
|
);
|
|
}
|
|
}
|
|
Err(err) => {
|
|
log::error!("Uninstall operation failed for {id}: {err}");
|
|
let _ = settle_and_end_operation(
|
|
ctx,
|
|
tx_notify_ui,
|
|
&target,
|
|
operation_guard,
|
|
"uninstall failure",
|
|
)
|
|
.await;
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::UninstallGameFailed { id: id.clone() },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn run_remove_downloaded_operation(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &PeerEventSender,
|
|
target: OperationTarget,
|
|
) {
|
|
let id = target.game_id().to_owned();
|
|
if !catalog_contains(ctx, &id) {
|
|
log::warn!("Ignoring downloaded-file removal for non-catalog game {id}");
|
|
return;
|
|
}
|
|
|
|
match begin_operation(ctx, tx_notify_ui, &target, 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;
|
|
}
|
|
BeginOperationResult::PublicationFailed => {
|
|
log::error!(
|
|
"Failed to withdraw published availability before downloaded-file removal of {id}"
|
|
);
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::RemoveDownloadedGameFailed { id: id.clone() },
|
|
);
|
|
return;
|
|
}
|
|
BeginOperationResult::RecoveryBlocked => {
|
|
log::warn!("Ignoring downloaded-file removal for recovery-blocked game {id}");
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::RemoveDownloadedGameFailed { id: id.clone() },
|
|
);
|
|
return;
|
|
}
|
|
BeginOperationResult::GameDirChanged => {
|
|
log::warn!(
|
|
"Game directory changed before downloaded-file removal admission for {id}; refusing to retarget the command"
|
|
);
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::RemoveDownloadedGameFailed { id: id.clone() },
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
let operation_guard = OperationGuard::new(id.clone());
|
|
let result = remove_downloaded_payload(&target.games_folder, ctx.state_dir.as_ref(), &id).await;
|
|
|
|
match result {
|
|
Ok(()) => {
|
|
if settle_and_end_operation(
|
|
ctx,
|
|
tx_notify_ui,
|
|
&target,
|
|
operation_guard,
|
|
"downloaded-file removal completion",
|
|
)
|
|
.await
|
|
{
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::RemoveDownloadedGameFinished { id: id.clone() },
|
|
);
|
|
} else {
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::RemoveDownloadedGameFailed { id: id.clone() },
|
|
);
|
|
}
|
|
}
|
|
Err(err) => {
|
|
log::error!("Downloaded-file removal failed for {id}: {err}");
|
|
let _ = settle_and_end_operation(
|
|
ctx,
|
|
tx_notify_ui,
|
|
&target,
|
|
operation_guard,
|
|
"downloaded-file removal failure",
|
|
)
|
|
.await;
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::RemoveDownloadedGameFailed { id: id.clone() },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum BeginOperationResult {
|
|
Started,
|
|
AlreadyActive,
|
|
GameDirChanged,
|
|
DrainTimedOut,
|
|
RecoveryBlocked,
|
|
PublicationFailed,
|
|
}
|
|
|
|
async fn begin_operation(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &PeerEventSender,
|
|
target: &OperationTarget,
|
|
operation: OperationKind,
|
|
) -> BeginOperationResult {
|
|
begin_operation_with_drain_timeout(
|
|
ctx,
|
|
tx_notify_ui,
|
|
target,
|
|
operation,
|
|
OUTBOUND_TRANSFER_DRAIN_TIMEOUT,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn begin_operation_with_drain_timeout(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &PeerEventSender,
|
|
target: &OperationTarget,
|
|
operation: OperationKind,
|
|
drain_timeout: Duration,
|
|
) -> BeginOperationResult {
|
|
let admission = ctx.operation_admission.lock().await;
|
|
let game_dir = ctx.game_dir.read().await;
|
|
if *game_dir != target.games_folder {
|
|
return BeginOperationResult::GameDirChanged;
|
|
}
|
|
if ctx
|
|
.recovery_quarantine
|
|
.is_blocked(&target.games_folder, target.game_id())
|
|
{
|
|
return BeginOperationResult::RecoveryBlocked;
|
|
}
|
|
drop(game_dir);
|
|
let started = {
|
|
let mut active_operations = ctx.active_operations.write().await;
|
|
if active_operations.contains_key(target.game_id()) {
|
|
false
|
|
} else {
|
|
// Acquire both guards before making the operation visible. A fresh
|
|
// Hello can therefore observe either the pre-operation projection
|
|
// or the withdrawn one, never an active operation that is still
|
|
// advertised as available.
|
|
let mut library = ctx.local_library.write().await;
|
|
let withdrawn_revision = match library.withdraw_for_operation(target.game_id()) {
|
|
Ok(revision) => revision,
|
|
Err(error) => {
|
|
log::error!(
|
|
"Cannot admit {operation:?} for {}: {error}",
|
|
target.game_id()
|
|
);
|
|
return BeginOperationResult::PublicationFailed;
|
|
}
|
|
};
|
|
active_operations.insert(target.game_id.clone(), operation);
|
|
|
|
if let Some(revision) = withdrawn_revision {
|
|
let game_db = GameDB::from(
|
|
library
|
|
.games
|
|
.values()
|
|
.map(game_from_summary)
|
|
.collect::<Vec<_>>(),
|
|
);
|
|
*ctx.local_game_db.write().await = Some(game_db.clone());
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::LocalLibraryChanged {
|
|
games: game_db.all_games().into_iter().cloned().collect(),
|
|
},
|
|
);
|
|
ctx.state_sync.publish_library_revision(revision);
|
|
}
|
|
true
|
|
}
|
|
};
|
|
|
|
if !started {
|
|
return BeginOperationResult::AlreadyActive;
|
|
}
|
|
|
|
// Once admitted, a directory change observes the active operation and is
|
|
// rejected. Release the admission barrier before emitting or draining.
|
|
drop(admission);
|
|
|
|
events::emit_active_operations(&ctx.active_operations, tx_notify_ui).await;
|
|
|
|
if operation_requires_outbound_drain(operation)
|
|
&& !cancel_and_wait_for_outbound_transfers(
|
|
ctx,
|
|
OutboundTransferScope::Game(target.game_id()),
|
|
drain_timeout,
|
|
)
|
|
.await
|
|
{
|
|
if let Err(error) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, target).await
|
|
{
|
|
// The withdrawn projection is safe to retain. Do not restore stale
|
|
// availability when the authoritative rescan cannot complete.
|
|
log::error!(
|
|
"Failed to restore local availability after outbound drain timeout for {}: {error}",
|
|
target.game_id()
|
|
);
|
|
}
|
|
end_operation(ctx, tx_notify_ui, target.game_id()).await;
|
|
return BeginOperationResult::DrainTimedOut;
|
|
}
|
|
|
|
BeginOperationResult::Started
|
|
}
|
|
|
|
fn operation_requires_outbound_drain(operation: OperationKind) -> bool {
|
|
matches!(
|
|
operation,
|
|
OperationKind::Downloading | OperationKind::Updating | OperationKind::RemovingDownload
|
|
)
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum OutboundTransferScope<'a> {
|
|
Game(&'a str),
|
|
All,
|
|
}
|
|
|
|
impl OutboundTransferScope<'_> {
|
|
fn matches(self, game_id: &str) -> bool {
|
|
match self {
|
|
Self::Game(target) => target == game_id,
|
|
Self::All => true,
|
|
}
|
|
}
|
|
|
|
fn description(self) -> String {
|
|
match self {
|
|
Self::Game(game_id) => format!("for {game_id}"),
|
|
Self::All => "across all games".to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn outbound_transfer_tokens(
|
|
active: &HashMap<String, Vec<(u64, CancellationToken)>>,
|
|
scope: OutboundTransferScope<'_>,
|
|
) -> Vec<CancellationToken> {
|
|
active
|
|
.iter()
|
|
.filter(|(game_id, _)| scope.matches(game_id))
|
|
.flat_map(|(_, transfers)| transfers.iter().map(|(_, token)| token.clone()))
|
|
.collect()
|
|
}
|
|
|
|
fn outbound_transfer_count(
|
|
active: &HashMap<String, Vec<(u64, CancellationToken)>>,
|
|
scope: OutboundTransferScope<'_>,
|
|
) -> usize {
|
|
active
|
|
.iter()
|
|
.filter(|(game_id, _)| scope.matches(game_id))
|
|
.map(|(_, transfers)| transfers.len())
|
|
.sum()
|
|
}
|
|
|
|
async fn cancel_and_wait_for_outbound_transfers(
|
|
ctx: &Ctx,
|
|
scope: OutboundTransferScope<'_>,
|
|
drain_timeout: Duration,
|
|
) -> bool {
|
|
let tokens_to_cancel = {
|
|
let active = ctx.active_outbound_transfers.read().await;
|
|
outbound_transfer_tokens(&active, scope)
|
|
};
|
|
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;
|
|
outbound_transfer_count(&active, scope)
|
|
};
|
|
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;
|
|
outbound_transfer_count(&active, scope)
|
|
};
|
|
let scope = scope.description();
|
|
log::error!(
|
|
"Timed out after {drain_timeout:?} waiting for {count} outbound transfer(s) to drain {scope}"
|
|
);
|
|
}
|
|
|
|
drained
|
|
}
|
|
|
|
async fn transition_download_to_install(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &PeerEventSender,
|
|
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: &PeerEventSender, 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, attempt: &DownloadAttemptKey) {
|
|
let mut active_downloads = ctx.active_downloads.write().await;
|
|
if active_downloads
|
|
.get(&attempt.id)
|
|
.is_some_and(|active| active.key() == attempt)
|
|
{
|
|
active_downloads.remove(&attempt.id);
|
|
}
|
|
}
|
|
|
|
fn send_download_failed(
|
|
tx_notify_ui: &PeerEventSender,
|
|
attempt: &DownloadAttemptKey,
|
|
reason: DownloadFailureReason,
|
|
) {
|
|
let status = DownloadAttemptStatus::new(
|
|
attempt.clone(),
|
|
CancellationToken::new(),
|
|
tx_notify_ui.clone(),
|
|
);
|
|
status.close_source_admission();
|
|
status.emit_failed(reason);
|
|
}
|
|
|
|
async fn end_download_operation(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &PeerEventSender,
|
|
attempt: &DownloadAttemptKey,
|
|
) {
|
|
clear_active_download(ctx, attempt).await;
|
|
end_operation(ctx, tx_notify_ui, &attempt.id).await;
|
|
}
|
|
|
|
async fn settle_target_state(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &PeerEventSender,
|
|
target: &OperationTarget,
|
|
) -> eyre::Result<()> {
|
|
install::recover_game_root(
|
|
&target.game_root(),
|
|
ctx.state_dir.as_ref(),
|
|
target.game_id(),
|
|
)
|
|
.await?;
|
|
refresh_local_game_for_ending_operation(ctx, tx_notify_ui, target).await
|
|
}
|
|
|
|
async fn settle_and_end_operation(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &PeerEventSender,
|
|
target: &OperationTarget,
|
|
guard: OperationGuard,
|
|
label: &str,
|
|
) -> bool {
|
|
if let Err(error) = settle_target_state(ctx, tx_notify_ui, target).await {
|
|
log::error!(
|
|
"Failed to settle {label} for {}: {error}; retaining the operation gate until process restart",
|
|
target.game_id()
|
|
);
|
|
return false;
|
|
}
|
|
|
|
// Settlement is complete before the guard is disarmed. If this task is
|
|
// cancelled during the async map cleanup, the still-present operation entry
|
|
// remains fail-closed; if removal already landed, recovery/publication is
|
|
// known complete.
|
|
guard.disarm();
|
|
end_operation(ctx, tx_notify_ui, target.game_id()).await;
|
|
true
|
|
}
|
|
|
|
async fn settle_and_end_download(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &PeerEventSender,
|
|
target: &OperationTarget,
|
|
guard: OperationGuard,
|
|
attempt: &DownloadAttemptKey,
|
|
label: &str,
|
|
) -> bool {
|
|
if let Err(error) = settle_target_state(ctx, tx_notify_ui, target).await {
|
|
log::error!(
|
|
"Failed to settle {label} for {}: {error}; retaining the operation gate until process restart",
|
|
target.game_id()
|
|
);
|
|
return false;
|
|
}
|
|
|
|
guard.disarm();
|
|
end_download_operation(ctx, tx_notify_ui, attempt).await;
|
|
true
|
|
}
|
|
|
|
async fn finish_successful_download_after_refresh(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &PeerEventSender,
|
|
target: &OperationTarget,
|
|
guard: OperationGuard,
|
|
status: &DownloadAttemptStatus,
|
|
) {
|
|
status.clear_activity();
|
|
if settle_and_end_download(
|
|
ctx,
|
|
tx_notify_ui,
|
|
target,
|
|
guard,
|
|
status.key(),
|
|
"download completion",
|
|
)
|
|
.await
|
|
{
|
|
status.emit_finished();
|
|
}
|
|
}
|
|
|
|
async fn finish_failed_download_after_refresh(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &PeerEventSender,
|
|
target: &OperationTarget,
|
|
guard: OperationGuard,
|
|
status: &DownloadAttemptStatus,
|
|
direct_reason: Option<DownloadFailureReason>,
|
|
) {
|
|
status.close_source_admission();
|
|
status.clear_activity();
|
|
if settle_and_end_download(
|
|
ctx,
|
|
tx_notify_ui,
|
|
target,
|
|
guard,
|
|
status.key(),
|
|
"download failure",
|
|
)
|
|
.await
|
|
&& let Some(reason) = status.resolve_failure(direct_reason)
|
|
{
|
|
status.emit_failed(reason);
|
|
}
|
|
}
|
|
|
|
fn catalog_contains(ctx: &Ctx, id: &str) -> bool {
|
|
ctx.catalog.catalog().contains(id)
|
|
}
|
|
|
|
async fn begin_local_recovery(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &PeerEventSender,
|
|
game_dir: &Path,
|
|
force_empty_snapshot: bool,
|
|
) -> eyre::Result<()> {
|
|
let (had_cached_games, revision) = {
|
|
let mut library = ctx.local_library.write().await;
|
|
let had_cached_games = !library.games.is_empty();
|
|
let revision = library.clear_for_recovery(game_dir)?;
|
|
(had_cached_games, revision)
|
|
};
|
|
ctx.recovery_quarantine.begin(game_dir.to_path_buf());
|
|
*ctx.local_game_db.write().await = None;
|
|
if force_empty_snapshot || had_cached_games {
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::LocalLibraryChanged { games: Vec::new() },
|
|
);
|
|
}
|
|
if let Some(revision) = revision {
|
|
ctx.state_sync.publish_library_revision(revision);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Handles the `SetGameDir` command.
|
|
///
|
|
/// Recovery, scanning, quarantine settlement, and library-delta delivery
|
|
/// attempts all complete before this function returns. `Ok` means the returned
|
|
/// canonical root is bound, even when a recovery failure left one or more games
|
|
/// quarantined. `Err` is returned only before changing the configured root.
|
|
pub async fn handle_set_game_dir_command(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &PeerEventSender,
|
|
game_dir: PathBuf,
|
|
) -> Result<PathBuf, String> {
|
|
handle_set_game_dir_command_with_drain_timeout(
|
|
ctx,
|
|
tx_notify_ui,
|
|
game_dir,
|
|
OUTBOUND_TRANSFER_DRAIN_TIMEOUT,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn handle_set_game_dir_command_with_drain_timeout(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &PeerEventSender,
|
|
requested_game_dir: PathBuf,
|
|
drain_timeout: Duration,
|
|
) -> Result<PathBuf, String> {
|
|
let _admission = ctx.operation_admission.lock().await;
|
|
let game_dir = match scoped_blocking(|| crate::canonicalize_game_dir(&requested_game_dir)) {
|
|
Ok(game_dir) => game_dir,
|
|
Err(error) => {
|
|
let error = error.to_string();
|
|
log::warn!(
|
|
"Rejecting invalid game directory {}: {error}",
|
|
requested_game_dir.display()
|
|
);
|
|
return Err(error);
|
|
}
|
|
};
|
|
if let Err(error) =
|
|
install::intent::scan_active_install_intents(ctx.state_dir.as_ref(), &game_dir)
|
|
{
|
|
let error = format!(
|
|
"cannot set game directory to {} while install intent state is unresolved: {error}",
|
|
game_dir.display()
|
|
);
|
|
log::warn!("{error}");
|
|
return Err(error);
|
|
}
|
|
let current_game_dir = ctx.game_dir.read().await.clone();
|
|
let active_ids = active_operation_ids(ctx).await;
|
|
if !active_ids.is_empty() {
|
|
let mut active_ids = active_ids.into_iter().collect::<Vec<_>>();
|
|
active_ids.sort();
|
|
let error = format!(
|
|
"cannot set game directory while operations are active for: {}",
|
|
active_ids.join(", ")
|
|
);
|
|
log::warn!(
|
|
"Rejecting game directory refresh/change to {} while operations are active for: {}",
|
|
game_dir.display(),
|
|
active_ids.join(", ")
|
|
);
|
|
return Err(error);
|
|
}
|
|
|
|
if !cancel_and_wait_for_outbound_transfers(ctx, OutboundTransferScope::All, drain_timeout).await
|
|
{
|
|
log::error!(
|
|
"Keeping game directory {} unchanged because outbound transfers did not drain",
|
|
current_game_dir.display()
|
|
);
|
|
return Err(format!(
|
|
"outbound transfers did not drain; game directory remains {}",
|
|
current_game_dir.display()
|
|
));
|
|
}
|
|
|
|
if current_game_dir == game_dir {
|
|
log::info!(
|
|
"Game directory {} unchanged; retrying inactive recovery before refresh",
|
|
game_dir.display()
|
|
);
|
|
if let Err(error) = begin_local_recovery(ctx, tx_notify_ui, &game_dir, true).await {
|
|
log::error!("Failed to begin local recovery: {error}");
|
|
return Err(format!("failed to begin local recovery: {error}"));
|
|
}
|
|
match load_local_library(ctx, tx_notify_ui).await {
|
|
Ok(()) => log::info!("Local game database refreshed successfully"),
|
|
Err(error) => log::error!(
|
|
"Game directory {} remains accepted but its library refresh failed: {error}",
|
|
game_dir.display()
|
|
),
|
|
}
|
|
return Ok(game_dir);
|
|
}
|
|
|
|
// Begin the target recovery epoch before changing the configured root.
|
|
// Requests that captured the old root now observe a root mismatch, while
|
|
// requests that later capture the new root observe Recovering.
|
|
if let Err(error) = begin_local_recovery(ctx, tx_notify_ui, &game_dir, true).await {
|
|
log::error!(
|
|
"Failed to begin recovery for {}: {error}",
|
|
game_dir.display()
|
|
);
|
|
return Err(format!(
|
|
"failed to begin recovery for {}: {error}",
|
|
game_dir.display()
|
|
));
|
|
}
|
|
*ctx.game_dir.write().await = game_dir.clone();
|
|
log::info!("Game directory set to: {}", game_dir.display());
|
|
|
|
match load_local_library_with_policy(ctx, tx_notify_ui, LocalLibraryEventPolicy::ForceSnapshot)
|
|
.await
|
|
{
|
|
Ok(()) => log::info!("Local game database loaded successfully"),
|
|
Err(error) => log::error!(
|
|
"Game directory {} remains accepted but its library load failed: {error}",
|
|
game_dir.display()
|
|
),
|
|
}
|
|
Ok(game_dir)
|
|
}
|
|
|
|
/// Loads the configured local library and announces the result.
|
|
pub async fn load_local_library(ctx: &Ctx, tx_notify_ui: &PeerEventSender) -> 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: &PeerEventSender,
|
|
event_policy: LocalLibraryEventPolicy,
|
|
) -> eyre::Result<()> {
|
|
let game_dir = { ctx.game_dir.read().await.clone() };
|
|
begin_local_recovery(ctx, tx_notify_ui, &game_dir, false).await?;
|
|
|
|
let active_operations = ctx.active_operations.read().await;
|
|
let active_ids = active_operations.keys().cloned().collect();
|
|
let recovery_report =
|
|
install::recover_on_startup(&game_dir, ctx.state_dir.as_ref(), &active_ids).await?;
|
|
drop(active_operations);
|
|
|
|
for (id, error) in recovery_report.failures() {
|
|
log::error!("Keeping game {id} quarantined after recovery failure: {error}");
|
|
}
|
|
let recovery_error = recovery_report.summary_error();
|
|
let failed_ids = recovery_report.failed_ids();
|
|
scan_and_announce_local_library(ctx, tx_notify_ui, &game_dir, event_policy, &failed_ids)
|
|
.await?;
|
|
|
|
if !ctx.recovery_quarantine.settle(&game_dir, failed_ids) {
|
|
eyre::bail!(
|
|
"local recovery result for {} was superseded before settlement",
|
|
game_dir.display()
|
|
);
|
|
}
|
|
match recovery_error {
|
|
Some(error) => Err(error),
|
|
None => Ok(()),
|
|
}
|
|
}
|
|
|
|
async fn scan_and_announce_local_library(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &PeerEventSender,
|
|
game_dir: &Path,
|
|
event_policy: LocalLibraryEventPolicy,
|
|
recovery_failed_ids: &HashSet<String>,
|
|
) -> eyre::Result<()> {
|
|
let catalog = ctx.catalog.catalog();
|
|
let scan = scan_local_library_with_recovery_failures(
|
|
game_dir,
|
|
ctx.state_dir.as_ref(),
|
|
catalog,
|
|
recovery_failed_ids,
|
|
)
|
|
.await?;
|
|
match update_and_announce_games_with_policy(ctx, tx_notify_ui, scan, event_policy, None).await {
|
|
LocalLibraryPublication::Published => Ok(()),
|
|
LocalLibraryPublication::Rejected => {
|
|
eyre::bail!("local library scan became obsolete before publication")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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: &PeerEventSender,
|
|
target: &OperationTarget,
|
|
) -> eyre::Result<()> {
|
|
let catalog = ctx.catalog.catalog();
|
|
let failed_ids = ctx.recovery_quarantine.failed_ids(&target.games_folder);
|
|
let scan = rescan_local_game_with_recovery_failures(
|
|
&target.games_folder,
|
|
ctx.state_dir.as_ref(),
|
|
catalog,
|
|
target.game_id(),
|
|
&failed_ids,
|
|
)
|
|
.await?;
|
|
match update_and_announce_games_with_policy(
|
|
ctx,
|
|
tx_notify_ui,
|
|
scan,
|
|
LocalLibraryEventPolicy::OnChange,
|
|
Some(target.game_id()),
|
|
)
|
|
.await
|
|
{
|
|
LocalLibraryPublication::Published => Ok(()),
|
|
LocalLibraryPublication::Rejected => {
|
|
eyre::bail!("local game refresh became obsolete before publication")
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum LocalLibraryEventPolicy {
|
|
OnChange,
|
|
ForceSnapshot,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum LocalLibraryPublication {
|
|
Published,
|
|
Rejected,
|
|
}
|
|
|
|
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: &PeerEventSender) {
|
|
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: &PeerEventSender,
|
|
endpoint: PeerEndpoint,
|
|
) {
|
|
log::info!("Direct connect command received for {}", endpoint.addr);
|
|
let network_permit = match ctx.network.try_acquire() {
|
|
Ok(permit) => permit,
|
|
Err(error) => {
|
|
log::warn!(
|
|
"Cannot directly connect to {} while Local network sharing is unavailable: {error}",
|
|
endpoint.addr
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
let network_ctx = network_permit.service_context(ctx.clone());
|
|
let handshake_ctx = HandshakeCtx::from_network(&network_ctx, tx_notify_ui);
|
|
let handshake = match ReservedCandidateHandshake::reserve(handshake_ctx, endpoint).await {
|
|
Ok(handshake) => handshake,
|
|
Err(err) => {
|
|
log::warn!(
|
|
"Failed to reserve direct connect to {}: {err}",
|
|
endpoint.addr
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
ctx.task_tracker
|
|
.spawn(retain_guard_until_operation_completes(
|
|
network_permit,
|
|
async move {
|
|
if let Err(err) = handshake.run().await {
|
|
log::warn!("Failed direct connect to {}: {err}", endpoint.addr);
|
|
}
|
|
},
|
|
));
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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: &PeerEventSender,
|
|
scan: LocalLibraryScan,
|
|
) {
|
|
let _ = 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: &PeerEventSender,
|
|
scan: LocalLibraryScan,
|
|
event_policy: LocalLibraryEventPolicy,
|
|
ending_operation_id: Option<&str>,
|
|
) -> LocalLibraryPublication {
|
|
let LocalLibraryScan {
|
|
source_game_dir,
|
|
mut game_db,
|
|
mut summaries,
|
|
revision,
|
|
} = scan;
|
|
|
|
// Hold the configured-root read guard through publication. A directory
|
|
// switch either happens first (and this scan is rejected) or waits until
|
|
// both cached library representations have been updated.
|
|
let current_game_dir = ctx.game_dir.read().await;
|
|
if *current_game_dir != source_game_dir {
|
|
log::debug!(
|
|
"Discarding local library scan from {} because the configured directory is now {}",
|
|
source_game_dir.display(),
|
|
current_game_dir.display()
|
|
);
|
|
return LocalLibraryPublication::Rejected;
|
|
}
|
|
|
|
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());
|
|
}
|
|
|
|
// Resolve every manifest needed by the prospective wire publication before
|
|
// making its revision visible. Hello/Pong responders are cache-only, so a
|
|
// malformed or unreadable artifact must reject the whole scan without
|
|
// partially mutating either local cache or emitting UI/state-sync updates.
|
|
let eligible = crate::library::catalog_eligible_game_ids(&summaries, ctx.catalog.catalog());
|
|
let catalog = Arc::clone(&ctx.catalog);
|
|
let summaries = match scoped_blocking(move || {
|
|
crate::library::prime_library_manifests(&eligible, &catalog)?;
|
|
Ok::<_, eyre::Report>(summaries)
|
|
}) {
|
|
Ok(summaries) => summaries,
|
|
Err(error) => {
|
|
log::error!("Rejecting local library publication: {error}");
|
|
return LocalLibraryPublication::Rejected;
|
|
}
|
|
};
|
|
|
|
let mut library_guard = ctx.local_library.write().await;
|
|
if let Some(published_revision) = library_guard.source_revision_for(&source_game_dir)
|
|
&& revision < published_revision
|
|
{
|
|
log::debug!(
|
|
"Discarding local library scan at source revision {revision} because source revision {published_revision} is already published"
|
|
);
|
|
return LocalLibraryPublication::Rejected;
|
|
}
|
|
let published_revision =
|
|
match library_guard.update_from_scan(&source_game_dir, summaries, revision) {
|
|
Ok(published_revision) => published_revision,
|
|
Err(error) => {
|
|
log::error!("Rejecting local library publication: {error}");
|
|
return LocalLibraryPublication::Rejected;
|
|
}
|
|
};
|
|
|
|
{
|
|
let mut db_guard = ctx.local_game_db.write().await;
|
|
*db_guard = Some(game_db.clone());
|
|
}
|
|
drop(library_guard);
|
|
drop(current_game_dir);
|
|
|
|
let all_games = game_db.all_games().into_iter().cloned().collect::<Vec<_>>();
|
|
|
|
if published_revision.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(published_revision) = published_revision else {
|
|
return LocalLibraryPublication::Published;
|
|
};
|
|
|
|
ctx.state_sync.publish_library_revision(published_revision);
|
|
|
|
LocalLibraryPublication::Published
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::{
|
|
collections::{BTreeMap, HashMap},
|
|
net::SocketAddr,
|
|
path::{Path, PathBuf},
|
|
sync::{
|
|
Arc,
|
|
atomic::{AtomicBool, Ordering},
|
|
},
|
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
|
};
|
|
|
|
use lanspread_db::{
|
|
content_manifest::{
|
|
Blake3Digest,
|
|
CATALOG_CONTENT_INDEX_NAME,
|
|
CatalogContentIdentity,
|
|
CatalogContentIndex,
|
|
CatalogContentIndexEntry,
|
|
CatalogContentManifestBody,
|
|
CatalogExtractedEntry,
|
|
CatalogFileEntry,
|
|
ContentId,
|
|
write_canonical_content_index_atomic,
|
|
},
|
|
db::Availability,
|
|
};
|
|
use lanspread_proto::{
|
|
GameAvailability,
|
|
LibrarySnapshot,
|
|
PeerEndpoint,
|
|
PeerId,
|
|
RuntimeSessionId,
|
|
};
|
|
use tokio::sync::{RwLock, oneshot};
|
|
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
|
|
|
use super::*;
|
|
use crate::{
|
|
ActiveOperation,
|
|
ActiveOperationKind,
|
|
CallToPlayLocalAction,
|
|
CallToPlayLocalIntent,
|
|
UnpackFuture,
|
|
Unpacker,
|
|
download::seed_pending_download_ownership_for_test,
|
|
identity::PeerIdentity,
|
|
install::intent::{InstallIntent, InstallIntentState, intent_path, write_intent},
|
|
network_generation::NetworkControl,
|
|
test_support::{TempDir, catalog_bundle},
|
|
};
|
|
|
|
struct FakeUnpacker;
|
|
|
|
struct DropFlag(Arc<AtomicBool>);
|
|
|
|
impl Drop for DropFlag {
|
|
fn drop(&mut self) {
|
|
self.0.store(true, Ordering::SeqCst);
|
|
}
|
|
}
|
|
|
|
impl Unpacker for FakeUnpacker {
|
|
fn unpack<'a>(
|
|
&'a self,
|
|
_archive: &'a Path,
|
|
dest: &'a Path,
|
|
_cancel_token: CancellationToken,
|
|
) -> 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 operation_target(game_dir: &Path) -> OperationTarget {
|
|
OperationTarget::new(game_dir.to_path_buf(), "game".to_string())
|
|
}
|
|
|
|
fn streamed_manifest(entries: Vec<CatalogExtractedEntry>) -> CatalogContentManifest {
|
|
let version = b"20250101";
|
|
let version_digest = Blake3Digest::hash(version);
|
|
CatalogContentManifest::seal(
|
|
CatalogContentManifestBody::new(
|
|
"game",
|
|
"20250101",
|
|
vec![
|
|
CatalogFileEntry::file(
|
|
"version.ini",
|
|
u64::try_from(version.len()).expect("test version length should fit u64"),
|
|
version_digest,
|
|
vec![version_digest],
|
|
)
|
|
.expect("test version entry should validate"),
|
|
],
|
|
entries,
|
|
)
|
|
.expect("test streamed manifest body should validate"),
|
|
)
|
|
.expect("test streamed manifest should seal")
|
|
}
|
|
|
|
fn downloadable_manifest() -> CatalogContentManifest {
|
|
let version = b"20250101";
|
|
let archive = b"archive";
|
|
let version_digest = Blake3Digest::hash(version);
|
|
let archive_digest = Blake3Digest::hash(archive);
|
|
CatalogContentManifest::seal(
|
|
CatalogContentManifestBody::new(
|
|
"game",
|
|
"20250101",
|
|
vec![
|
|
CatalogFileEntry::file(
|
|
"game.eti",
|
|
u64::try_from(archive.len()).expect("test archive length should fit u64"),
|
|
archive_digest,
|
|
vec![archive_digest],
|
|
)
|
|
.expect("test archive entry should validate"),
|
|
CatalogFileEntry::file(
|
|
"version.ini",
|
|
u64::try_from(version.len()).expect("test version length should fit u64"),
|
|
version_digest,
|
|
vec![version_digest],
|
|
)
|
|
.expect("test version entry should validate"),
|
|
],
|
|
Vec::new(),
|
|
)
|
|
.expect("test download manifest body should validate"),
|
|
)
|
|
.expect("test download manifest should seal")
|
|
}
|
|
|
|
async fn seed_exact_download_ownership(ctx: &Ctx, games_folder: &Path, content_id: ContentId) {
|
|
crate::download::seed_download_ownership_for_test(
|
|
ctx.state_dir.as_ref(),
|
|
games_folder,
|
|
"game",
|
|
&["game.eti"],
|
|
)
|
|
.await;
|
|
let games_folder_key = crate::state_paths::games_folder_key(games_folder);
|
|
let record_path = crate::state_paths::download_ownership_path(
|
|
ctx.state_dir.as_ref(),
|
|
"game",
|
|
&games_folder_key,
|
|
);
|
|
let mut record: serde_json::Value = serde_json::from_slice(
|
|
&std::fs::read(&record_path).expect("seeded ownership should be readable"),
|
|
)
|
|
.expect("seeded ownership should be valid JSON");
|
|
record["committed_content_id"] = serde_json::Value::String(content_id.to_string());
|
|
std::fs::write(
|
|
record_path,
|
|
serde_json::to_vec(&record).expect("updated ownership should serialize"),
|
|
)
|
|
.expect("updated ownership should be written");
|
|
}
|
|
|
|
fn test_ctx(game_dir: PathBuf) -> Ctx {
|
|
test_ctx_with_catalog(game_dir, catalog_bundle([("game", "20250101")]))
|
|
}
|
|
|
|
fn test_ctx_with_catalog(
|
|
game_dir: PathBuf,
|
|
catalog: Arc<lanspread_db::content_manifest::CatalogBundle>,
|
|
) -> Ctx {
|
|
test_ctx_with_catalog_and_network(game_dir, catalog, NetworkControl::enabled_for_test())
|
|
}
|
|
|
|
fn test_ctx_with_catalog_and_network(
|
|
game_dir: PathBuf,
|
|
catalog: Arc<lanspread_db::content_manifest::CatalogBundle>,
|
|
network: NetworkControl,
|
|
) -> Ctx {
|
|
let state_dir = game_dir.join(".test-state");
|
|
let recovery_root = game_dir.clone();
|
|
let ctx = Ctx::new(
|
|
Arc::new(RwLock::new(PeerGameDB::new())),
|
|
Arc::new(PeerIdentity::generate().expect("test identity should generate")),
|
|
game_dir,
|
|
state_dir,
|
|
Arc::new(FakeUnpacker),
|
|
CancellationToken::new(),
|
|
TaskTracker::new(),
|
|
catalog,
|
|
Arc::new(RwLock::new(HashMap::new())),
|
|
Arc::new(crate::NoopStreamInstallProvider),
|
|
network,
|
|
)
|
|
.expect("test context should initialize");
|
|
assert!(
|
|
ctx.recovery_quarantine
|
|
.settle(&recovery_root, HashSet::new())
|
|
);
|
|
ctx
|
|
}
|
|
|
|
async fn register_test_download(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &PeerEventSender,
|
|
) -> (DownloadAttemptStatus, CancellationToken) {
|
|
let cancellation = CancellationToken::new();
|
|
let status = register_download_attempt(
|
|
ctx,
|
|
tx_notify_ui,
|
|
DownloadAttemptKey::next("game".to_owned()),
|
|
cancellation.clone(),
|
|
)
|
|
.await;
|
|
(status, cancellation)
|
|
}
|
|
|
|
fn create_call_to_play_intent() -> CallToPlayLocalIntent {
|
|
let now = i64::try_from(
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.expect("system time should follow the Unix epoch")
|
|
.as_millis(),
|
|
)
|
|
.expect("current Unix time should fit i64");
|
|
CallToPlayLocalIntent {
|
|
call_id: None,
|
|
action: CallToPlayLocalAction::Create {
|
|
game_id: "game".to_owned(),
|
|
max_players: 4,
|
|
scheduled_for: None,
|
|
deadline: now + 60_000,
|
|
},
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn cancelled_download_owner_does_not_emit_failed_event() {
|
|
let (tx, mut rx) = crate::peer_event_channel();
|
|
let status = DownloadAttemptStatus::new(
|
|
DownloadAttemptKey::next("game".to_owned()),
|
|
CancellationToken::new(),
|
|
tx,
|
|
);
|
|
|
|
assert!(status.signal().cancel_silently());
|
|
status.close_source_admission();
|
|
status.clear_activity();
|
|
|
|
assert_eq!(status.resolve_failure(None), None);
|
|
assert!(rx.try_recv().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn uncancelled_download_error_emits_failed_event() {
|
|
let (tx, mut rx) = crate::peer_event_channel();
|
|
let attempt = DownloadAttemptKey::next("game".to_owned());
|
|
|
|
send_download_failed(&tx, &attempt, DownloadFailureReason::OperationFailed);
|
|
|
|
assert!(matches!(
|
|
rx.try_recv(),
|
|
Ok(PeerEvent::DownloadGameFilesFailed {
|
|
attempt: emitted_attempt,
|
|
reason: DownloadFailureReason::OperationFailed,
|
|
}) if emitted_attempt == attempt
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn retained_guard_outlives_cancelled_operation_cleanup() {
|
|
let dropped = Arc::new(AtomicBool::new(false));
|
|
let cancel_token = CancellationToken::new();
|
|
let task_cancel_token = cancel_token.clone();
|
|
let (started_tx, started_rx) = oneshot::channel();
|
|
let (cleanup_tx, cleanup_rx) = oneshot::channel();
|
|
let (release_tx, release_rx) = oneshot::channel();
|
|
|
|
let task = tokio::spawn(retain_guard_until_operation_completes(
|
|
DropFlag(Arc::clone(&dropped)),
|
|
async move {
|
|
started_tx
|
|
.send(())
|
|
.expect("operation-start signal should be observed");
|
|
task_cancel_token.cancelled().await;
|
|
cleanup_tx
|
|
.send(())
|
|
.expect("cleanup-start signal should be observed");
|
|
release_rx
|
|
.await
|
|
.expect("operation cleanup should be released");
|
|
},
|
|
));
|
|
|
|
started_rx
|
|
.await
|
|
.expect("retained operation should report startup");
|
|
cancel_token.cancel();
|
|
cleanup_rx
|
|
.await
|
|
.expect("retained operation should enter cleanup");
|
|
assert!(!dropped.load(Ordering::SeqCst));
|
|
assert!(!task.is_finished());
|
|
|
|
release_tx
|
|
.send(())
|
|
.expect("cleanup release should reach retained operation");
|
|
task.await.expect("retained operation should not panic");
|
|
assert!(dropped.load(Ordering::SeqCst));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn disabled_network_uses_exact_cached_download_without_peer_db_access() {
|
|
let games = TempDir::new("lanspread-handler-disabled-cached-download");
|
|
write_file(&games.game_root().join("version.ini"), b"20250101");
|
|
write_file(&games.game_root().join("game.eti"), b"archive");
|
|
let manifest = downloadable_manifest();
|
|
let content_id = manifest.content_id();
|
|
let catalog = Arc::new(
|
|
lanspread_db::content_manifest::CatalogBundle::from_manifests([manifest])
|
|
.expect("test catalog should validate"),
|
|
);
|
|
let ctx = test_ctx_with_catalog_and_network(
|
|
games.path().to_path_buf(),
|
|
catalog,
|
|
NetworkControl::disabled_for_test(),
|
|
);
|
|
seed_exact_download_ownership(&ctx, games.path(), content_id).await;
|
|
let peer_db = ctx.peer_game_db.write().await;
|
|
let (tx, mut rx) = crate::peer_event_channel();
|
|
|
|
tokio::time::timeout(
|
|
Duration::from_secs(1),
|
|
handle_download_game_files_command(&ctx, &tx, "game".to_owned(), false),
|
|
)
|
|
.await
|
|
.expect("cached download must not wait for PeerDB while sharing is disabled");
|
|
drop(peer_db);
|
|
|
|
let begin_attempt = match recv_event(&mut rx).await {
|
|
PeerEvent::DownloadGameFilesBegin { attempt } => attempt,
|
|
event => panic!("expected download begin, got {event:?}"),
|
|
};
|
|
let finished_attempt = match recv_event(&mut rx).await {
|
|
PeerEvent::DownloadGameFilesFinished { attempt } => attempt,
|
|
event => panic!("expected download finish, got {event:?}"),
|
|
};
|
|
assert_eq!(begin_attempt, finished_attempt);
|
|
assert_eq!(begin_attempt.id, "game");
|
|
assert!(ctx.active_operations.read().await.is_empty());
|
|
assert!(ctx.active_downloads.read().await.is_empty());
|
|
assert_no_event(&mut rx).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn disabled_network_rejects_nonlocal_download_before_peer_db_or_mutation() {
|
|
let games = TempDir::new("lanspread-handler-disabled-download");
|
|
let ctx = test_ctx_with_catalog_and_network(
|
|
games.path().to_path_buf(),
|
|
catalog_bundle([("game", "20250101")]),
|
|
NetworkControl::disabled_for_test(),
|
|
);
|
|
let peer_db = ctx.peer_game_db.write().await;
|
|
let (tx, mut rx) = crate::peer_event_channel();
|
|
|
|
tokio::time::timeout(
|
|
Duration::from_secs(1),
|
|
handle_download_game_files_command(&ctx, &tx, "game".to_owned(), false),
|
|
)
|
|
.await
|
|
.expect("disabled download must not wait for PeerDB");
|
|
drop(peer_db);
|
|
|
|
assert!(matches!(
|
|
recv_event(&mut rx).await,
|
|
PeerEvent::DownloadGameFilesFailed {
|
|
attempt,
|
|
reason: DownloadFailureReason::OperationFailed,
|
|
} if attempt.id == "game"
|
|
));
|
|
assert!(!games.game_root().exists());
|
|
assert!(ctx.active_operations.read().await.is_empty());
|
|
assert!(ctx.active_downloads.read().await.is_empty());
|
|
assert_no_event(&mut rx).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn disabled_network_rejects_stream_install_before_peer_db_or_staging() {
|
|
let games = TempDir::new("lanspread-handler-disabled-stream-install");
|
|
let manifest = streamed_manifest(vec![
|
|
CatalogExtractedEntry::file("payload.txt", 7, Blake3Digest::hash(b"payload"))
|
|
.expect("test streamed entry should validate"),
|
|
]);
|
|
let catalog = Arc::new(
|
|
lanspread_db::content_manifest::CatalogBundle::from_manifests([manifest])
|
|
.expect("test catalog should validate"),
|
|
);
|
|
let ctx = test_ctx_with_catalog_and_network(
|
|
games.path().to_path_buf(),
|
|
catalog,
|
|
NetworkControl::disabled_for_test(),
|
|
);
|
|
let peer_db = ctx.peer_game_db.write().await;
|
|
let (tx, mut rx) = crate::peer_event_channel();
|
|
|
|
tokio::time::timeout(
|
|
Duration::from_secs(1),
|
|
handle_stream_install_game_command(
|
|
&ctx,
|
|
&tx,
|
|
"game".to_owned(),
|
|
StreamInstallSettings::default(),
|
|
),
|
|
)
|
|
.await
|
|
.expect("disabled streamed install must not wait for PeerDB");
|
|
drop(peer_db);
|
|
|
|
assert!(matches!(
|
|
recv_event(&mut rx).await,
|
|
PeerEvent::DownloadGameFilesFailed {
|
|
attempt,
|
|
reason: DownloadFailureReason::OperationFailed,
|
|
} if attempt.id == "game"
|
|
));
|
|
assert!(!games.game_root().exists());
|
|
assert!(ctx.active_operations.read().await.is_empty());
|
|
assert!(ctx.active_downloads.read().await.is_empty());
|
|
assert_no_event(&mut rx).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn disabled_network_rejects_direct_connect_before_ticket_reservation() {
|
|
let games = TempDir::new("lanspread-handler-disabled-direct-connect");
|
|
let ctx = test_ctx_with_catalog_and_network(
|
|
games.path().to_path_buf(),
|
|
catalog_bundle([("game", "20250101")]),
|
|
NetworkControl::disabled_for_test(),
|
|
);
|
|
let peer_db = ctx.peer_game_db.write().await;
|
|
let (tx, mut rx) = crate::peer_event_channel();
|
|
|
|
tokio::time::timeout(
|
|
Duration::from_secs(1),
|
|
handle_connect_peer_command(&ctx, &tx, endpoint(7, 12_007)),
|
|
)
|
|
.await
|
|
.expect("disabled direct connect must not wait for PeerDB");
|
|
drop(peer_db);
|
|
|
|
assert_eq!(
|
|
ctx.peer_game_db.read().await.negotiation_claim_counts(),
|
|
(0, 0)
|
|
);
|
|
assert_no_event(&mut rx).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn disabled_network_rejects_call_to_play_before_store_mutation() {
|
|
let games = TempDir::new("lanspread-handler-disabled-call-to-play");
|
|
let ctx = test_ctx_with_catalog_and_network(
|
|
games.path().to_path_buf(),
|
|
catalog_bundle([("game", "20250101")]),
|
|
NetworkControl::disabled_for_test(),
|
|
);
|
|
let mut store = ctx.call_to_play.write().await;
|
|
let before = store
|
|
.current_publication()
|
|
.expect("initial Call-to-Play projection should load")
|
|
.view;
|
|
let (tx, mut rx) = crate::peer_event_channel();
|
|
let (reply_tx, reply_rx) = oneshot::channel();
|
|
|
|
tokio::time::timeout(
|
|
Duration::from_secs(1),
|
|
crate::handle_apply_call_to_play_intent(
|
|
&ctx,
|
|
&tx,
|
|
create_call_to_play_intent(),
|
|
"Player".to_owned(),
|
|
reply_tx,
|
|
),
|
|
)
|
|
.await
|
|
.expect("closed admission must reject without waiting for the held store lock");
|
|
assert!(
|
|
reply_rx
|
|
.await
|
|
.expect("handler should reply")
|
|
.expect_err("disabled admission must reject")
|
|
.contains("disabled or changing state")
|
|
);
|
|
let after = store
|
|
.current_publication()
|
|
.expect("unchanged Call-to-Play projection should load")
|
|
.view;
|
|
assert_eq!(after, before);
|
|
drop(store);
|
|
assert_no_event(&mut rx).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn enabled_call_to_play_admission_waits_then_mutates_once() {
|
|
let games = TempDir::new("lanspread-handler-enabled-call-to-play");
|
|
let ctx = test_ctx(games.path().to_path_buf());
|
|
let store = ctx.call_to_play.write().await;
|
|
let (tx, mut rx) = crate::peer_event_channel();
|
|
let (reply_tx, mut reply_rx) = oneshot::channel();
|
|
let task = tokio::spawn({
|
|
let ctx = ctx.clone();
|
|
let tx = tx.clone();
|
|
async move {
|
|
crate::handle_apply_call_to_play_intent(
|
|
&ctx,
|
|
&tx,
|
|
create_call_to_play_intent(),
|
|
"Player".to_owned(),
|
|
reply_tx,
|
|
)
|
|
.await;
|
|
}
|
|
});
|
|
|
|
tokio::task::yield_now().await;
|
|
assert!(matches!(
|
|
reply_rx.try_recv(),
|
|
Err(tokio::sync::oneshot::error::TryRecvError::Empty)
|
|
));
|
|
drop(store);
|
|
task.await
|
|
.expect("admitted Call-to-Play task should finish");
|
|
let receipt = reply_rx
|
|
.await
|
|
.expect("handler should reply")
|
|
.expect("enabled admission should accept the intent");
|
|
assert_eq!(receipt.revision, 1);
|
|
assert!(matches!(
|
|
recv_event(&mut rx).await,
|
|
PeerEvent::CallToPlayView(view) if view.events.len() == 1
|
|
));
|
|
assert_no_event(&mut rx).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn streamed_install_without_extracted_catalog_manifest_fails_before_admission() {
|
|
let games = TempDir::new("lanspread-handler-stream-capability");
|
|
let ctx = test_ctx(games.path().to_path_buf());
|
|
let (tx, mut rx) = crate::peer_event_channel();
|
|
|
|
handle_stream_install_game_command(
|
|
&ctx,
|
|
&tx,
|
|
"game".to_string(),
|
|
StreamInstallSettings::default(),
|
|
)
|
|
.await;
|
|
|
|
assert!(matches!(
|
|
recv_event(&mut rx).await,
|
|
PeerEvent::DownloadGameFilesFailed {
|
|
attempt,
|
|
reason: DownloadFailureReason::OperationFailed,
|
|
} if attempt.id == "game"
|
|
));
|
|
assert!(ctx.active_operations.read().await.is_empty());
|
|
assert!(ctx.active_downloads.read().await.is_empty());
|
|
assert_no_event(&mut rx).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn version_only_local_download_never_shortcuts_exact_catalog_ownership() {
|
|
let games = TempDir::new("lanspread-handler-local-content-ownership");
|
|
write_file(&games.game_root().join("version.ini"), b"20250101");
|
|
write_file(&games.game_root().join("game.eti"), b"archive");
|
|
let ctx = test_ctx(games.path().to_path_buf());
|
|
crate::download::seed_download_ownership_for_test(
|
|
ctx.state_dir.as_ref(),
|
|
games.path(),
|
|
"game",
|
|
&["game.eti"],
|
|
)
|
|
.await;
|
|
let catalog_content_id = ctx
|
|
.catalog
|
|
.manifest("game")
|
|
.expect("test manifest should load")
|
|
.content_id();
|
|
assert!(
|
|
!download_ownership_matches_content(
|
|
games.path(),
|
|
ctx.state_dir.as_ref(),
|
|
"game",
|
|
catalog_content_id,
|
|
)
|
|
.await
|
|
);
|
|
let (tx, mut rx) = crate::peer_event_channel();
|
|
|
|
handle_download_game_files_command(&ctx, &tx, "game".to_string(), false).await;
|
|
|
|
assert!(matches!(
|
|
recv_event(&mut rx).await,
|
|
PeerEvent::DownloadGameFilesFailed {
|
|
attempt,
|
|
reason: DownloadFailureReason::VerifiedCatalogSourcesExhausted,
|
|
} if attempt.id == "game"
|
|
));
|
|
assert!(ctx.active_operations.read().await.is_empty());
|
|
assert!(ctx.active_downloads.read().await.is_empty());
|
|
assert_no_event(&mut rx).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn already_active_download_emits_no_status_for_the_new_attempt() {
|
|
let games = TempDir::new("lanspread-handler-download-already-active");
|
|
let manifest = downloadable_manifest();
|
|
let content_id = manifest.content_id();
|
|
let catalog = Arc::new(
|
|
lanspread_db::content_manifest::CatalogBundle::from_manifests([manifest])
|
|
.expect("test catalog should validate"),
|
|
);
|
|
let ctx = test_ctx_with_catalog(games.path().to_path_buf(), catalog);
|
|
{
|
|
let mut peer_game_db = ctx.peer_game_db.write().await;
|
|
upsert(
|
|
&mut peer_game_db,
|
|
endpoint(1, 12_001),
|
|
Some(("game", content_id)),
|
|
);
|
|
}
|
|
ctx.active_operations
|
|
.write()
|
|
.await
|
|
.insert("game".to_owned(), OperationKind::Downloading);
|
|
let (tx, mut rx) = crate::peer_event_channel();
|
|
|
|
handle_download_game_files_command(&ctx, &tx, "game".to_owned(), false).await;
|
|
|
|
assert_eq!(
|
|
ctx.active_operations.read().await.get("game"),
|
|
Some(&OperationKind::Downloading)
|
|
);
|
|
assert!(ctx.active_downloads.read().await.is_empty());
|
|
assert_no_event(&mut rx).await;
|
|
}
|
|
|
|
#[test]
|
|
fn streamed_install_marks_settings_only_after_successful_promotion() {
|
|
let games = TempDir::new("lanspread-handler-stream-promotion");
|
|
let state = TempDir::new("lanspread-handler-stream-promotion-state");
|
|
let marker = crate::state_paths::launch_settings_applied_path(state.path(), "game");
|
|
let transaction = install::begin_streamed_install(&games.game_root(), state.path(), "game")
|
|
.expect("streamed install transaction should begin");
|
|
write_file(&transaction.staging_dir().join("payload.txt"), b"installed");
|
|
|
|
assert!(!marker.exists());
|
|
let manifest = streamed_manifest(vec![
|
|
CatalogExtractedEntry::file("payload.txt", 9, Blake3Digest::hash(b"installed"))
|
|
.expect("test streamed entry should validate"),
|
|
]);
|
|
promote_streamed_install(
|
|
state.path(),
|
|
"game",
|
|
transaction,
|
|
&manifest,
|
|
&CancellationToken::new(),
|
|
)
|
|
.expect("verified staging should be promoted");
|
|
|
|
assert_eq!(
|
|
std::fs::read(games.game_root().join("local/payload.txt"))
|
|
.expect("promoted payload should be readable"),
|
|
b"installed"
|
|
);
|
|
assert!(marker.is_file());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn recovery_required_completion_settles_before_success() {
|
|
let games = TempDir::new("lanspread-handler-download-recovery");
|
|
let root = games.game_root();
|
|
write_file(&root.join("version.ini"), b"20250101");
|
|
write_file(&root.join("archive.eti"), b"archive");
|
|
let ctx = test_ctx(games.path().to_path_buf());
|
|
seed_pending_download_ownership_for_test(
|
|
ctx.state_dir.as_ref(),
|
|
games.path(),
|
|
"game",
|
|
&["archive.eti"],
|
|
&["archive.eti"],
|
|
)
|
|
.await;
|
|
|
|
settle_download_completion(
|
|
&ctx,
|
|
&operation_target(games.path()),
|
|
DownloadCompletion::RecoveryRequired(eyre::eyre!("injected uncertainty")),
|
|
)
|
|
.await
|
|
.expect("committed download recovery should settle");
|
|
|
|
assert_eq!(
|
|
download_ownership_readiness(games.path(), ctx.state_dir.as_ref(), "game").await,
|
|
DownloadOwnershipReadiness::Settled
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn failed_completion_recovery_remains_quarantined() {
|
|
let games = TempDir::new("lanspread-handler-download-recovery-failure");
|
|
let root = games.game_root();
|
|
write_file(&root.join("version.ini"), b"20250101");
|
|
write_file(&root.join("archive.eti"), b"archive");
|
|
std::fs::create_dir_all(root.join(".version.ini.tmp"))
|
|
.expect("invalid scratch directory should be created");
|
|
let ctx = test_ctx(games.path().to_path_buf());
|
|
seed_pending_download_ownership_for_test(
|
|
ctx.state_dir.as_ref(),
|
|
games.path(),
|
|
"game",
|
|
&["archive.eti"],
|
|
&["archive.eti"],
|
|
)
|
|
.await;
|
|
|
|
let error = settle_download_completion(
|
|
&ctx,
|
|
&operation_target(games.path()),
|
|
DownloadCompletion::RecoveryRequired(eyre::eyre!("injected uncertainty")),
|
|
)
|
|
.await
|
|
.expect_err("invalid scratch shape should keep recovery quarantined");
|
|
|
|
assert!(
|
|
error
|
|
.to_string()
|
|
.contains("download recovery did not settle")
|
|
);
|
|
assert_eq!(
|
|
download_ownership_readiness(games.path(), ctx.state_dir.as_ref(), "game").await,
|
|
DownloadOwnershipReadiness::RecoveryRequired
|
|
);
|
|
let scan = scan_local_library(games.path(), ctx.state_dir.as_ref(), ctx.catalog.catalog())
|
|
.await
|
|
.expect("quarantined library should still scan");
|
|
let game = scan
|
|
.summaries
|
|
.get("game")
|
|
.expect("quarantined game should remain visible");
|
|
assert!(!game.downloaded);
|
|
assert_eq!(game.availability, Availability::LocalOnly);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn failed_root_stays_quarantined_while_healthy_root_loads_and_retry_clears() {
|
|
let games = TempDir::new("lanspread-handler-runtime-recovery");
|
|
let broken = games.path().join("broken");
|
|
let healthy = games.path().join("healthy");
|
|
for root in [&broken, &healthy] {
|
|
write_file(&root.join("version.ini"), b"20250101");
|
|
write_file(&root.join("archive.eti"), b"archive");
|
|
write_file(&root.join("local/payload.txt"), b"installed");
|
|
}
|
|
std::fs::create_dir_all(broken.join(".version.ini.tmp"))
|
|
.expect("invalid scratch directory should be created");
|
|
write_file(&healthy.join(".version.ini.tmp"), b"stale scratch");
|
|
|
|
let ctx = test_ctx_with_catalog(
|
|
games.path().to_path_buf(),
|
|
catalog_bundle([("broken", "20250101"), ("healthy", "20250101")]),
|
|
);
|
|
let (tx, _rx) = crate::peer_event_channel();
|
|
|
|
let error = load_local_library(&ctx, &tx)
|
|
.await
|
|
.expect_err("per-game recovery failure should remain visible to the caller");
|
|
assert!(error.to_string().contains("broken"));
|
|
|
|
assert!(ctx.recovery_quarantine.is_blocked(games.path(), "broken"));
|
|
assert!(!ctx.recovery_quarantine.is_blocked(games.path(), "healthy"));
|
|
let library = ctx.local_library.read().await;
|
|
assert!(!library.games["broken"].downloaded);
|
|
assert!(!library.games["broken"].installed);
|
|
assert!(library.games["healthy"].downloaded);
|
|
assert!(library.games["healthy"].installed);
|
|
drop(library);
|
|
assert!(!healthy.join(".version.ini.tmp").exists());
|
|
|
|
std::fs::remove_dir_all(broken.join(".version.ini.tmp"))
|
|
.expect("invalid scratch directory should be removable");
|
|
load_local_library(&ctx, &tx)
|
|
.await
|
|
.expect("corrected recovery should settle");
|
|
|
|
assert!(!ctx.recovery_quarantine.is_blocked(games.path(), "broken"));
|
|
let library = ctx.local_library.read().await;
|
|
assert!(library.games["broken"].downloaded);
|
|
assert!(library.games["broken"].installed);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn startup_recovery_quarantines_symlink_game_root_without_scanning_outside() {
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let games = TempDir::new("lanspread-handler-symlink-recovery-games");
|
|
let outside = TempDir::new("lanspread-handler-symlink-recovery-outside");
|
|
write_file(&outside.path().join("version.ini"), b"20250101");
|
|
write_file(&outside.path().join("archive.eti"), b"archive");
|
|
write_file(&outside.path().join("local/payload.txt"), b"installed");
|
|
write_file(&outside.path().join("canary.txt"), b"outside");
|
|
symlink(outside.path(), games.path().join("game"))
|
|
.expect("game-root symlink should be created");
|
|
|
|
let ctx = test_ctx(games.path().to_path_buf());
|
|
let (tx, _rx) = crate::peer_event_channel();
|
|
|
|
let error = load_local_library(&ctx, &tx)
|
|
.await
|
|
.expect_err("unsafe game root should remain a visible recovery failure");
|
|
|
|
assert!(error.to_string().contains("game"));
|
|
assert!(ctx.recovery_quarantine.is_blocked(games.path(), "game"));
|
|
assert!(!ctx.local_library.read().await.games.contains_key("game"));
|
|
assert_eq!(
|
|
std::fs::read(outside.path().join("canary.txt"))
|
|
.expect("canary should remain readable"),
|
|
b"outside"
|
|
);
|
|
assert_eq!(
|
|
std::fs::read(outside.path().join("local/payload.txt"))
|
|
.expect("outside install should remain readable"),
|
|
b"installed"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn operation_admission_rejects_recovering_and_failed_games() {
|
|
let games = TempDir::new("lanspread-handler-recovery-admission");
|
|
let ctx = test_ctx(games.path().to_path_buf());
|
|
let (tx, _rx) = crate::peer_event_channel();
|
|
|
|
ctx.recovery_quarantine.begin(games.path().to_path_buf());
|
|
let target = operation_target(games.path());
|
|
assert_eq!(
|
|
begin_operation(&ctx, &tx, &target, OperationKind::Downloading).await,
|
|
BeginOperationResult::RecoveryBlocked
|
|
);
|
|
assert!(ctx.active_operations.read().await.is_empty());
|
|
|
|
assert!(
|
|
ctx.recovery_quarantine
|
|
.settle(games.path(), HashSet::from(["game".to_string()]))
|
|
);
|
|
assert_eq!(
|
|
begin_operation(&ctx, &tx, &target, OperationKind::Downloading).await,
|
|
BeginOperationResult::RecoveryBlocked
|
|
);
|
|
assert!(ctx.active_operations.read().await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn final_download_refresh_failure_keeps_fail_closed_operation_gate() {
|
|
let games = TempDir::new("lanspread-handler-download-refresh-failure");
|
|
write_file(&games.game_root(), b"not a directory");
|
|
let ctx = test_ctx(games.path().to_path_buf());
|
|
let (tx, mut rx) = crate::peer_event_channel();
|
|
let (status, cancel) = register_test_download(&ctx, &tx).await;
|
|
ctx.active_operations
|
|
.write()
|
|
.await
|
|
.insert("game".to_string(), OperationKind::Downloading);
|
|
let guard = OperationGuard::download("game".to_string(), cancel.clone());
|
|
|
|
finish_successful_download_after_refresh(
|
|
&ctx,
|
|
&tx,
|
|
&operation_target(games.path()),
|
|
guard,
|
|
&status,
|
|
)
|
|
.await;
|
|
|
|
assert!(ctx.active_operations.read().await.contains_key("game"));
|
|
assert!(ctx.active_downloads.read().await.contains_key("game"));
|
|
assert!(cancel.is_cancelled());
|
|
assert_no_event(&mut rx).await;
|
|
}
|
|
|
|
async fn recv_event(rx: &mut crate::PeerEventReceiver) -> 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 crate::PeerEventReceiver) {
|
|
assert!(
|
|
tokio::time::timeout(Duration::from_millis(50), rx.recv())
|
|
.await
|
|
.is_err(),
|
|
"event channel should stay quiet"
|
|
);
|
|
}
|
|
|
|
fn drain_events(rx: &mut crate::PeerEventReceiver) -> Vec<PeerEvent> {
|
|
let mut events = Vec::new();
|
|
while let Ok(event) = rx.try_recv() {
|
|
events.push(event);
|
|
}
|
|
events
|
|
}
|
|
|
|
fn addr(port: u16) -> SocketAddr {
|
|
SocketAddr::from(([127, 0, 0, 1], port))
|
|
}
|
|
|
|
fn peer_id(seed: u8) -> PeerId {
|
|
PeerId::from_bytes([seed; 32])
|
|
}
|
|
|
|
fn endpoint(seed: u8, port: u16) -> PeerEndpoint {
|
|
PeerEndpoint::new(peer_id(seed), addr(port))
|
|
}
|
|
|
|
fn upsert(db: &mut PeerGameDB, endpoint: PeerEndpoint, game: Option<(&str, ContentId)>) {
|
|
let ticket = db
|
|
.begin_candidate_negotiation(endpoint)
|
|
.expect("authenticated test peer should reserve");
|
|
db.commit_authenticated_snapshot(
|
|
endpoint,
|
|
ticket,
|
|
RuntimeSessionId::from_bytes([1; 16]),
|
|
Some(LibrarySnapshot {
|
|
revision: 1,
|
|
games: game
|
|
.into_iter()
|
|
.map(|(game_id, content_id)| GameAvailability {
|
|
game_id: game_id.to_owned(),
|
|
content_id,
|
|
})
|
|
.collect(),
|
|
}),
|
|
)
|
|
.expect("authenticated snapshot should commit")
|
|
.expect("candidate ticket should be current");
|
|
}
|
|
|
|
fn summary(
|
|
id: &str,
|
|
version: &str,
|
|
availability: Availability,
|
|
) -> crate::library::LocalGameSummary {
|
|
crate::library::LocalGameSummary {
|
|
id: id.to_string(),
|
|
name: id.to_string(),
|
|
size: 42,
|
|
downloaded: availability == Availability::Ready,
|
|
installed: true,
|
|
eti_version: Some(version.to_string()),
|
|
availability,
|
|
}
|
|
}
|
|
|
|
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: &[ActiveOperation]) {
|
|
let PeerEvent::ActiveOperationsChanged { active_operations } = event else {
|
|
panic!("expected ActiveOperationsChanged");
|
|
};
|
|
assert_eq!(active_operations, expected);
|
|
}
|
|
|
|
fn active_update(id: &str, operation: ActiveOperationKind) -> [ActiveOperation; 1] {
|
|
[ActiveOperation {
|
|
id: id.to_string(),
|
|
operation,
|
|
}]
|
|
}
|
|
|
|
#[test]
|
|
fn content_sources_require_exact_content_and_authenticated_identity() {
|
|
let matching_a = endpoint(20, 12_100);
|
|
let matching_b = endpoint(21, 12_101);
|
|
let wrong_content = endpoint(22, 12_102);
|
|
let no_game = endpoint(23, 12_103);
|
|
let content_id = ContentId::from_bytes([1; 32]);
|
|
let other_content_id = ContentId::from_bytes([2; 32]);
|
|
let mut db = PeerGameDB::new();
|
|
upsert(&mut db, matching_a, Some(("game", content_id)));
|
|
upsert(&mut db, matching_b, Some(("game", content_id)));
|
|
upsert(&mut db, wrong_content, Some(("game", other_content_id)));
|
|
upsert(&mut db, no_game, None);
|
|
|
|
let quarantine = ContentQuarantine::default();
|
|
quarantine.record_integrity_failure(&matching_a, content_id);
|
|
|
|
assert_eq!(
|
|
select_content_sources(&db, "game", content_id, &quarantine),
|
|
vec![matching_b]
|
|
);
|
|
assert_eq!(
|
|
select_content_sources(&db, "game", other_content_id, &quarantine),
|
|
vec![wrong_content]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn streamed_install_retries_and_quarantines_only_typed_integrity_failures() {
|
|
assert_eq!(
|
|
stream_install_failure_disposition(StreamInstallReceiveErrorKind::Integrity),
|
|
StreamInstallFailureDisposition::RetryAndQuarantine
|
|
);
|
|
assert_eq!(
|
|
stream_install_failure_disposition(StreamInstallReceiveErrorKind::Transport),
|
|
StreamInstallFailureDisposition::Retry
|
|
);
|
|
assert_eq!(
|
|
stream_install_failure_disposition(StreamInstallReceiveErrorKind::Cancelled),
|
|
StreamInstallFailureDisposition::Stop
|
|
);
|
|
assert_eq!(
|
|
stream_install_failure_disposition(StreamInstallReceiveErrorKind::Setup),
|
|
StreamInstallFailureDisposition::Stop
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn alternate_stream_setup_failure_does_not_emit_retry_activity() {
|
|
let games = TempDir::new("lanspread-handler-stream-retry-setup-failure");
|
|
let state = TempDir::new("lanspread-handler-stream-retry-setup-state");
|
|
std::fs::create_dir_all(games.game_root().join("local"))
|
|
.expect("installed tree should be created");
|
|
let (tx, mut rx) = crate::peer_event_channel();
|
|
let status = DownloadAttemptStatus::new(
|
|
DownloadAttemptKey::next("game".to_owned()),
|
|
CancellationToken::new(),
|
|
tx,
|
|
);
|
|
let mut retry_invalid_source = true;
|
|
|
|
let error = match begin_stream_receive_attempt(
|
|
&games.game_root(),
|
|
state.path(),
|
|
"game",
|
|
&mut retry_invalid_source,
|
|
&status.reporter(),
|
|
) {
|
|
Ok(transaction) => {
|
|
transaction
|
|
.rollback()
|
|
.expect("unexpected transaction should roll back");
|
|
panic!("already-installed staging setup must fail");
|
|
}
|
|
Err(error) => error,
|
|
};
|
|
|
|
assert_eq!(error.reason, Some(DownloadFailureReason::OperationFailed));
|
|
assert!(retry_invalid_source);
|
|
assert!(rx.try_recv().is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn active_game_projection_uses_peer_acceptable_revisions() {
|
|
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) = crate::peer_event_channel();
|
|
let catalog = ctx.catalog.catalog();
|
|
|
|
// 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");
|
|
let source_revision = scan.revision;
|
|
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");
|
|
|
|
let initial_snapshot = {
|
|
let library = ctx.local_library.read().await;
|
|
crate::library::build_library_snapshot(
|
|
library.publication(ctx.catalog.catalog()),
|
|
&ctx.catalog,
|
|
)
|
|
.expect("catalog snapshot should build")
|
|
};
|
|
let initial_revision = initial_snapshot.revision;
|
|
assert_eq!(initial_snapshot.games.len(), 1);
|
|
|
|
// 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");
|
|
assert_eq!(scan.revision, source_revision);
|
|
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"
|
|
);
|
|
let hidden_snapshot = {
|
|
let library = ctx.local_library.read().await;
|
|
crate::library::build_library_snapshot(
|
|
library.publication(ctx.catalog.catalog()),
|
|
&ctx.catalog,
|
|
)
|
|
.expect("hidden catalog snapshot should build")
|
|
};
|
|
assert!(hidden_snapshot.revision > initial_revision);
|
|
assert!(hidden_snapshot.games.is_empty());
|
|
let hidden_revision = hidden_snapshot.revision;
|
|
|
|
// 3. Operation completion republishes the game from the same unchanged
|
|
// disk revision. The operation remains registered until publication,
|
|
// so the ending ID is explicitly excluded from the hidden projection.
|
|
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), catalog)
|
|
.await
|
|
.expect("ending-operation scan should succeed");
|
|
assert_eq!(scan.revision, source_revision);
|
|
assert_eq!(
|
|
update_and_announce_games_with_policy(
|
|
&ctx,
|
|
&tx,
|
|
scan,
|
|
LocalLibraryEventPolicy::OnChange,
|
|
Some("game"),
|
|
)
|
|
.await,
|
|
LocalLibraryPublication::Published
|
|
);
|
|
|
|
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
|
|
panic!("expected LocalLibraryChanged");
|
|
};
|
|
assert_eq!(games.len(), 1);
|
|
assert_eq!(games[0].id, "game");
|
|
let restored_snapshot = {
|
|
let library = ctx.local_library.read().await;
|
|
crate::library::build_library_snapshot(
|
|
library.publication(ctx.catalog.catalog()),
|
|
&ctx.catalog,
|
|
)
|
|
.expect("restored catalog snapshot should build")
|
|
};
|
|
assert!(restored_snapshot.revision > hidden_revision);
|
|
assert_eq!(restored_snapshot.games.len(), 1);
|
|
assert_eq!(restored_snapshot.games[0].game_id, "game");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_prospective_manifest_rejects_publication_without_visible_mutation() {
|
|
let games = TempDir::new("lanspread-handler-invalid-publication-game-root");
|
|
let root = games.game_root();
|
|
write_file(&root.join("version.ini"), b"20250101");
|
|
write_file(&root.join("game.eti"), b"archive");
|
|
|
|
let manifests = TempDir::new("lanspread-handler-invalid-publication-manifests");
|
|
write_file(&manifests.path().join("game.json"), b"not JSON");
|
|
let content_index = CatalogContentIndex::from_entries([CatalogContentIndexEntry {
|
|
game_id: "game".to_owned(),
|
|
game_version: "20250101".to_owned(),
|
|
identity: CatalogContentIdentity {
|
|
content_id: ContentId::from_bytes([1; 32]),
|
|
supports_streamed_install: false,
|
|
},
|
|
}])
|
|
.expect("test content index should validate");
|
|
write_canonical_content_index_atomic(
|
|
&manifests.path().join(CATALOG_CONTENT_INDEX_NAME),
|
|
&content_index,
|
|
)
|
|
.expect("test content index should publish");
|
|
let catalog = Arc::new(
|
|
lanspread_db::content_manifest::CatalogBundle::new(
|
|
manifests.path(),
|
|
BTreeMap::from([("game".to_owned(), "20250101".to_owned())]),
|
|
)
|
|
.expect("catalog construction should defer manifest body parsing"),
|
|
);
|
|
let ctx = test_ctx_with_catalog(games.path().to_path_buf(), catalog);
|
|
let (tx, mut rx) = crate::peer_event_channel();
|
|
let scan = scan_local_library(games.path(), ctx.state_dir.as_ref(), ctx.catalog.catalog())
|
|
.await
|
|
.expect("ready game should scan before manifest priming");
|
|
|
|
assert_eq!(
|
|
update_and_announce_games_with_policy(
|
|
&ctx,
|
|
&tx,
|
|
scan,
|
|
LocalLibraryEventPolicy::OnChange,
|
|
None,
|
|
)
|
|
.await,
|
|
LocalLibraryPublication::Rejected
|
|
);
|
|
|
|
let library = ctx.local_library.read().await;
|
|
assert_eq!(library.revision, 0);
|
|
assert!(library.games.is_empty());
|
|
drop(library);
|
|
assert!(ctx.local_game_db.read().await.is_none());
|
|
assert_no_event(&mut rx).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn local_library_rejects_scan_from_previous_game_directory() {
|
|
let current = TempDir::new("lanspread-handler-current-scan-root");
|
|
let previous = TempDir::new("lanspread-handler-previous-scan-root");
|
|
write_file(¤t.game_root().join("version.ini"), b"20250101");
|
|
write_file(¤t.game_root().join("game.eti"), b"archive");
|
|
write_file(&previous.game_root().join("version.ini"), b"20250101");
|
|
write_file(&previous.game_root().join("game.eti"), b"archive");
|
|
write_file(
|
|
&previous.game_root().join("local/payload.txt"),
|
|
b"installed",
|
|
);
|
|
|
|
let ctx = test_ctx(current.path().to_path_buf());
|
|
let (tx, mut rx) = crate::peer_event_channel();
|
|
let catalog = ctx.catalog.catalog();
|
|
|
|
let current_scan = scan_local_library(current.path(), ctx.state_dir.as_ref(), catalog)
|
|
.await
|
|
.expect("current root should scan");
|
|
update_and_announce_games(&ctx, &tx, current_scan).await;
|
|
assert_local_update(recv_event(&mut rx).await, false, true);
|
|
|
|
let obsolete_scan = scan_local_library(previous.path(), ctx.state_dir.as_ref(), catalog)
|
|
.await
|
|
.expect("previous root should scan");
|
|
update_and_announce_games(&ctx, &tx, obsolete_scan).await;
|
|
|
|
assert_no_event(&mut rx).await;
|
|
let library = ctx.local_library.read().await;
|
|
assert!(!library.games["game"].installed);
|
|
drop(library);
|
|
let game_db = ctx.local_game_db.read().await;
|
|
assert!(
|
|
!game_db
|
|
.as_ref()
|
|
.expect("current database should remain published")
|
|
.get_game_by_id("game")
|
|
.expect("current game should remain published")
|
|
.installed
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn local_library_rejects_scan_older_than_published_revision() {
|
|
let temp = TempDir::new("lanspread-handler-stale-scan");
|
|
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) = crate::peer_event_channel();
|
|
let catalog = ctx.catalog.catalog();
|
|
let older_scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), catalog)
|
|
.await
|
|
.expect("older snapshot should scan");
|
|
|
|
seed_pending_download_ownership_for_test(
|
|
ctx.state_dir.as_ref(),
|
|
temp.path(),
|
|
"game",
|
|
&["game.eti"],
|
|
&["game.eti"],
|
|
)
|
|
.await;
|
|
let newer_scan = rescan_local_game(temp.path(), ctx.state_dir.as_ref(), catalog, "game")
|
|
.await
|
|
.expect("quarantined snapshot should scan");
|
|
assert!(newer_scan.revision > older_scan.revision);
|
|
let newer_revision = newer_scan.revision;
|
|
|
|
update_and_announce_games(&ctx, &tx, newer_scan).await;
|
|
assert_local_update(recv_event(&mut rx).await, false, false);
|
|
update_and_announce_games(&ctx, &tx, older_scan).await;
|
|
|
|
assert_no_event(&mut rx).await;
|
|
let library = ctx.local_library.read().await;
|
|
assert_eq!(
|
|
library.source_revision_for(temp.path()),
|
|
Some(newer_revision)
|
|
);
|
|
assert!(!library.games["game"].downloaded);
|
|
assert_eq!(library.games["game"].availability, Availability::LocalOnly);
|
|
drop(library);
|
|
let game_db = ctx.local_game_db.read().await;
|
|
assert!(
|
|
!game_db
|
|
.as_ref()
|
|
.expect("newer database should remain published")
|
|
.get_game_by_id("game")
|
|
.expect("newer game should remain published")
|
|
.downloaded
|
|
);
|
|
}
|
|
|
|
#[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) = crate::peer_event_channel();
|
|
let target = operation_target(temp.path());
|
|
|
|
assert_eq!(
|
|
begin_operation(&ctx, &tx, &target, OperationKind::Updating).await,
|
|
BeginOperationResult::Started
|
|
);
|
|
assert_active_update(
|
|
recv_event(&mut rx).await,
|
|
&[ActiveOperation {
|
|
id: "game".to_string(),
|
|
operation: ActiveOperationKind::Updating,
|
|
}],
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn begin_operation_withdraws_fresh_library_snapshot_before_mutation() {
|
|
let temp = TempDir::new("lanspread-handler-active-withdrawal");
|
|
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) = crate::peer_event_channel();
|
|
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), ctx.catalog.catalog())
|
|
.await
|
|
.expect("initial scan should succeed");
|
|
update_and_announce_games(&ctx, &tx, scan).await;
|
|
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
|
|
panic!("expected initial LocalLibraryChanged");
|
|
};
|
|
assert_eq!(games.len(), 1);
|
|
let initial_revision = ctx.local_library.read().await.revision;
|
|
|
|
assert_eq!(
|
|
begin_operation(
|
|
&ctx,
|
|
&tx,
|
|
&operation_target(temp.path()),
|
|
OperationKind::Installing,
|
|
)
|
|
.await,
|
|
BeginOperationResult::Started
|
|
);
|
|
|
|
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
|
|
panic!("availability withdrawal must precede the active-operation event");
|
|
};
|
|
assert!(games.is_empty());
|
|
assert_active_update(
|
|
recv_event(&mut rx).await,
|
|
&active_update("game", ActiveOperationKind::Installing),
|
|
);
|
|
|
|
let snapshot = {
|
|
let library = ctx.local_library.read().await;
|
|
assert!(library.revision > initial_revision);
|
|
crate::library::build_library_snapshot(
|
|
library.publication(ctx.catalog.catalog()),
|
|
&ctx.catalog,
|
|
)
|
|
.expect("withdrawn snapshot should build")
|
|
};
|
|
assert!(snapshot.games.is_empty());
|
|
assert!(
|
|
ctx.local_game_db
|
|
.read()
|
|
.await
|
|
.as_ref()
|
|
.expect("local database should remain published")
|
|
.all_games()
|
|
.is_empty()
|
|
);
|
|
}
|
|
|
|
#[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) = crate::peer_event_channel();
|
|
let target = operation_target(temp.path());
|
|
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), ctx.catalog.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);
|
|
let initial_revision = ctx.local_library.read().await.revision;
|
|
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,
|
|
&target,
|
|
OperationKind::Updating,
|
|
Duration::from_millis(1),
|
|
)
|
|
.await,
|
|
BeginOperationResult::DrainTimedOut
|
|
);
|
|
|
|
assert!(token.is_cancelled());
|
|
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
|
|
panic!("operation admission should withdraw availability first");
|
|
};
|
|
assert!(games.is_empty());
|
|
assert_active_update(
|
|
recv_event(&mut rx).await,
|
|
&[ActiveOperation {
|
|
id: "game".to_string(),
|
|
operation: ActiveOperationKind::Updating,
|
|
}],
|
|
);
|
|
assert_local_update(recv_event(&mut rx).await, false, true);
|
|
assert_active_update(recv_event(&mut rx).await, &[]);
|
|
assert!(
|
|
!ctx.active_operations.read().await.contains_key("game"),
|
|
"timed-out drain should not leave the operation stuck active"
|
|
);
|
|
let snapshot = {
|
|
let library = ctx.local_library.read().await;
|
|
assert_eq!(library.revision, initial_revision + 2);
|
|
crate::library::build_library_snapshot(
|
|
library.publication(ctx.catalog.catalog()),
|
|
&ctx.catalog,
|
|
)
|
|
.expect("restored snapshot should build")
|
|
};
|
|
assert_eq!(snapshot.games.len(), 1);
|
|
assert_eq!(snapshot.games[0].game_id, "game");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn begin_operation_revision_exhaustion_is_fail_closed() {
|
|
let temp = TempDir::new("lanspread-handler-active-revision-exhaustion");
|
|
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) = crate::peer_event_channel();
|
|
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), ctx.catalog.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);
|
|
|
|
let before_games = {
|
|
let mut library = ctx.local_library.write().await;
|
|
library.revision = u64::MAX;
|
|
library.games.clone()
|
|
};
|
|
let before_db = ctx
|
|
.local_game_db
|
|
.read()
|
|
.await
|
|
.as_ref()
|
|
.expect("initial database should be published")
|
|
.clone();
|
|
|
|
assert_eq!(
|
|
begin_operation(
|
|
&ctx,
|
|
&tx,
|
|
&operation_target(temp.path()),
|
|
OperationKind::Installing,
|
|
)
|
|
.await,
|
|
BeginOperationResult::PublicationFailed
|
|
);
|
|
|
|
assert!(ctx.active_operations.read().await.is_empty());
|
|
let library = ctx.local_library.read().await;
|
|
assert_eq!(library.revision, u64::MAX);
|
|
assert_eq!(library.games, before_games);
|
|
drop(library);
|
|
assert_eq!(
|
|
ctx.local_game_db
|
|
.read()
|
|
.await
|
|
.as_ref()
|
|
.expect("database should remain published")
|
|
.all_games(),
|
|
before_db.all_games(),
|
|
);
|
|
assert_no_event(&mut rx).await;
|
|
}
|
|
|
|
#[test]
|
|
fn payload_replacing_operations_require_outbound_transfer_drain() {
|
|
assert!(operation_requires_outbound_drain(
|
|
OperationKind::Downloading
|
|
));
|
|
assert!(operation_requires_outbound_drain(OperationKind::Updating));
|
|
assert!(operation_requires_outbound_drain(
|
|
OperationKind::RemovingDownload
|
|
));
|
|
assert!(!operation_requires_outbound_drain(
|
|
OperationKind::Installing
|
|
));
|
|
assert!(!operation_requires_outbound_drain(
|
|
OperationKind::Uninstalling
|
|
));
|
|
}
|
|
|
|
#[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) = crate::peer_event_channel();
|
|
let catalog = ctx.catalog.catalog();
|
|
|
|
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) = crate::peer_event_channel();
|
|
let catalog = ctx.catalog.catalog();
|
|
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, operation_target(temp.path())).await;
|
|
|
|
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
|
|
panic!("operation admission should withdraw the ready game");
|
|
};
|
|
assert!(games.is_empty());
|
|
assert_active_update(
|
|
recv_event(&mut rx).await,
|
|
&active_update("game", ActiveOperationKind::Updating),
|
|
);
|
|
assert_local_update(recv_event(&mut rx).await, true, true);
|
|
assert_active_update(recv_event(&mut rx).await, &[]);
|
|
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) = crate::peer_event_channel();
|
|
|
|
run_install_operation(&ctx, &tx, operation_target(temp.path())).await;
|
|
|
|
assert_active_update(
|
|
recv_event(&mut rx).await,
|
|
&active_update("game", ActiveOperationKind::Installing),
|
|
);
|
|
assert_local_update(recv_event(&mut rx).await, true, true);
|
|
assert_active_update(recv_event(&mut rx).await, &[]);
|
|
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_rechecks_new_ownership_quarantine_after_admission() {
|
|
let temp = TempDir::new("lanspread-handler-install-stale-preflight");
|
|
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 target = operation_target(temp.path());
|
|
let (tx, mut rx) = crate::peer_event_channel();
|
|
|
|
let prepared = prepare_install_operation(&ctx, &tx, &target)
|
|
.await
|
|
.expect("initial preflight should accept the settled download");
|
|
assert_eq!(
|
|
begin_operation(&ctx, &tx, &target, prepared.operation_kind).await,
|
|
BeginOperationResult::Started
|
|
);
|
|
let operation_guard = OperationGuard::new("game".to_string());
|
|
seed_pending_download_ownership_for_test(
|
|
ctx.state_dir.as_ref(),
|
|
temp.path(),
|
|
"game",
|
|
&["game.eti"],
|
|
&["game.eti"],
|
|
)
|
|
.await;
|
|
|
|
assert!(
|
|
revalidate_install_operation(&ctx, &tx, &target, prepared.operation_kind)
|
|
.await
|
|
.is_none(),
|
|
"post-admission ownership quarantine must invalidate the stale preflight"
|
|
);
|
|
assert!(
|
|
settle_and_end_operation(
|
|
&ctx,
|
|
&tx,
|
|
&target,
|
|
operation_guard,
|
|
"test stale install preflight",
|
|
)
|
|
.await
|
|
);
|
|
|
|
assert!(!root.join("local").exists());
|
|
assert!(ctx.active_operations.read().await.is_empty());
|
|
assert!(
|
|
drain_events(&mut rx)
|
|
.iter()
|
|
.any(|event| matches!(event, PeerEvent::InstallGameFailed { id } if id == "game"))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn streamed_install_rechecks_new_ownership_quarantine_after_admission() {
|
|
let temp = TempDir::new("lanspread-handler-stream-stale-preflight");
|
|
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 target = operation_target(temp.path());
|
|
let (tx, mut rx) = crate::peer_event_channel();
|
|
|
|
assert!(stream_install_target_is_ready(&ctx, &target).await);
|
|
assert_eq!(
|
|
begin_operation(&ctx, &tx, &target, OperationKind::Downloading).await,
|
|
BeginOperationResult::Started
|
|
);
|
|
let (status, cancel) = register_test_download(&ctx, &tx).await;
|
|
let guard = OperationGuard::download("game".to_string(), cancel);
|
|
seed_pending_download_ownership_for_test(
|
|
ctx.state_dir.as_ref(),
|
|
temp.path(),
|
|
"game",
|
|
&["game.eti"],
|
|
&["game.eti"],
|
|
)
|
|
.await;
|
|
|
|
assert!(!stream_install_target_is_ready(&ctx, &target).await);
|
|
finish_failed_stream_download(
|
|
&ctx,
|
|
&tx,
|
|
&target,
|
|
guard,
|
|
&status,
|
|
Some(DownloadFailureReason::OperationFailed),
|
|
)
|
|
.await;
|
|
|
|
assert!(!root.join("local").exists());
|
|
assert!(ctx.active_operations.read().await.is_empty());
|
|
assert!(ctx.active_downloads.read().await.is_empty());
|
|
assert!(drain_events(&mut rx).iter().any(|event| matches!(
|
|
event,
|
|
PeerEvent::DownloadGameFilesFailed {
|
|
attempt,
|
|
reason: DownloadFailureReason::OperationFailed,
|
|
} if attempt.id == "game"
|
|
)));
|
|
}
|
|
|
|
#[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());
|
|
let (prepare_tx, _prepare_rx) = crate::peer_event_channel();
|
|
let (download_status, _download_cancel) = register_test_download(&ctx, &prepare_tx).await;
|
|
ctx.active_operations
|
|
.write()
|
|
.await
|
|
.insert("game".to_string(), OperationKind::Downloading);
|
|
let target = operation_target(temp.path());
|
|
let prepared = prepare_install_operation(&ctx, &prepare_tx, &target)
|
|
.await
|
|
.expect("downloaded game should be installable");
|
|
let read_guard = ctx.active_operations.read().await;
|
|
let (tx, mut rx) = crate::peer_event_channel();
|
|
|
|
let install_task = tokio::spawn({
|
|
let ctx = ctx.clone();
|
|
let tx = tx.clone();
|
|
let target = target.clone();
|
|
let attempt = download_status.key().clone();
|
|
async move {
|
|
assert!(
|
|
transition_download_to_install(&ctx, &tx, "game", prepared.operation_kind)
|
|
.await
|
|
);
|
|
clear_active_download(&ctx, &attempt).await;
|
|
let cancel_token = CancellationToken::new();
|
|
let operation_guard =
|
|
OperationGuard::cancellable("game".to_string(), cancel_token.clone());
|
|
run_started_install_operation(
|
|
&ctx,
|
|
&tx,
|
|
target,
|
|
prepared,
|
|
operation_guard,
|
|
cancel_token,
|
|
)
|
|
.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),
|
|
);
|
|
assert_local_update(recv_event(&mut rx).await, true, true);
|
|
assert_active_update(recv_event(&mut rx).await, &[]);
|
|
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 (tx, mut rx) = crate::peer_event_channel();
|
|
let (_status, cancel) = register_test_download(&ctx, &tx).await;
|
|
ctx.active_operations
|
|
.write()
|
|
.await
|
|
.insert("game".to_string(), OperationKind::Downloading);
|
|
|
|
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) = crate::peer_event_channel();
|
|
|
|
run_install_operation(&ctx, &tx, operation_target(temp.path())).await;
|
|
|
|
assert_active_update(
|
|
recv_event(&mut rx).await,
|
|
&active_update("game", ActiveOperationKind::Updating),
|
|
);
|
|
assert_local_update(recv_event(&mut rx).await, true, true);
|
|
assert_active_update(recv_event(&mut rx).await, &[]);
|
|
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) = crate::peer_event_channel();
|
|
|
|
run_install_operation(&ctx, &tx, operation_target(temp.path())).await;
|
|
assert_active_update(
|
|
recv_event(&mut rx).await,
|
|
&active_update("game", ActiveOperationKind::Installing),
|
|
);
|
|
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, &[]);
|
|
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, operation_target(temp.path())).await;
|
|
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
|
|
panic!("update admission should withdraw the ready game");
|
|
};
|
|
assert!(games.is_empty());
|
|
assert_active_update(
|
|
recv_event(&mut rx).await,
|
|
&active_update("game", ActiveOperationKind::Updating),
|
|
);
|
|
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, &[]);
|
|
assert!(matches!(
|
|
recv_event(&mut rx).await,
|
|
PeerEvent::InstallGameFinished { id } if id == "game"
|
|
));
|
|
|
|
run_uninstall_operation(&ctx, &tx, operation_target(temp.path())).await;
|
|
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
|
|
panic!("uninstall admission should withdraw the ready game");
|
|
};
|
|
assert!(games.is_empty());
|
|
assert_active_update(
|
|
recv_event(&mut rx).await,
|
|
&active_update("game", ActiveOperationKind::Uninstalling),
|
|
);
|
|
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, &[]);
|
|
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) = crate::peer_event_channel();
|
|
|
|
run_uninstall_operation(&ctx, &tx, operation_target(temp.path())).await;
|
|
|
|
assert_active_update(
|
|
recv_event(&mut rx).await,
|
|
&active_update("game", ActiveOperationKind::Uninstalling),
|
|
);
|
|
assert_local_update(recv_event(&mut rx).await, false, true);
|
|
assert_active_update(recv_event(&mut rx).await, &[]);
|
|
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());
|
|
crate::download::seed_download_ownership_for_test(
|
|
ctx.state_dir.as_ref(),
|
|
temp.path(),
|
|
"game",
|
|
&["game.eti"],
|
|
)
|
|
.await;
|
|
let (tx, mut rx) = crate::peer_event_channel();
|
|
let catalog = ctx.catalog.catalog();
|
|
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, operation_target(temp.path())).await;
|
|
|
|
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
|
|
panic!("download removal admission should withdraw the ready game");
|
|
};
|
|
assert!(games.is_empty());
|
|
assert_active_update(
|
|
recv_event(&mut rx).await,
|
|
&active_update("game", ActiveOperationKind::RemovingDownload),
|
|
);
|
|
assert_active_update(recv_event(&mut rx).await, &[]);
|
|
assert!(matches!(
|
|
recv_event(&mut rx).await,
|
|
PeerEvent::RemoveDownloadedGameFinished { id } if id == "game"
|
|
));
|
|
assert!(root.is_dir());
|
|
assert!(!root.join("version.ini").exists());
|
|
assert!(!root.join("game.eti").exists());
|
|
assert!(ctx.active_operations.read().await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_command_does_not_retarget_after_set_game_dir() {
|
|
let current = TempDir::new("lanspread-handler-install-captured-root");
|
|
let next = TempDir::new("lanspread-handler-install-next-root");
|
|
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) = crate::peer_event_channel();
|
|
|
|
// On the current-thread test runtime, this ready lock acquisition does
|
|
// not yield to the newly spawned command. Move the configured root
|
|
// after capture so its operation must reject the stale target.
|
|
handle_install_game_command(&ctx, &tx, "game".to_string()).await;
|
|
*ctx.game_dir.write().await = next.path().to_path_buf();
|
|
|
|
assert_eq!(*ctx.game_dir.read().await, next.path());
|
|
assert!(!current.game_root().join("local").exists());
|
|
assert!(!next.game_root().join("local").exists());
|
|
let terminal_event = tokio::time::timeout(Duration::from_secs(1), async {
|
|
loop {
|
|
let event = recv_event(&mut rx).await;
|
|
if matches!(event, PeerEvent::InstallGameFailed { ref id } if id == "game") {
|
|
break event;
|
|
}
|
|
assert!(!matches!(
|
|
event,
|
|
PeerEvent::InstallGameFinished { ref id } if id == "game"
|
|
));
|
|
}
|
|
})
|
|
.await
|
|
.expect("captured-root install should report failure");
|
|
assert!(matches!(
|
|
terminal_event,
|
|
PeerEvent::InstallGameFailed { id } if id == "game"
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn uninstall_command_does_not_retarget_after_set_game_dir() {
|
|
let current = TempDir::new("lanspread-handler-uninstall-captured-root");
|
|
let next = TempDir::new("lanspread-handler-uninstall-next-root");
|
|
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");
|
|
write_file(&root.join("local/payload.txt"), b"installed");
|
|
}
|
|
let ctx = test_ctx(current.path().to_path_buf());
|
|
let (tx, mut rx) = crate::peer_event_channel();
|
|
|
|
handle_uninstall_game_command(&ctx, &tx, "game".to_string()).await;
|
|
*ctx.game_dir.write().await = next.path().to_path_buf();
|
|
|
|
assert_eq!(*ctx.game_dir.read().await, next.path());
|
|
assert!(current.game_root().join("local/payload.txt").is_file());
|
|
assert!(next.game_root().join("local/payload.txt").is_file());
|
|
let terminal_event = tokio::time::timeout(Duration::from_secs(1), async {
|
|
loop {
|
|
let event = recv_event(&mut rx).await;
|
|
if matches!(event, PeerEvent::UninstallGameFailed { ref id } if id == "game") {
|
|
break event;
|
|
}
|
|
assert!(!matches!(
|
|
event,
|
|
PeerEvent::UninstallGameFinished { ref id } if id == "game"
|
|
));
|
|
}
|
|
})
|
|
.await
|
|
.expect("captured-root uninstall should report failure");
|
|
assert!(matches!(
|
|
terminal_event,
|
|
PeerEvent::UninstallGameFailed { id } if id == "game"
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn remove_downloaded_command_does_not_retarget_after_set_game_dir() {
|
|
let current = TempDir::new("lanspread-handler-remove-captured-root");
|
|
let next = TempDir::new("lanspread-handler-remove-next-root");
|
|
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());
|
|
crate::download::seed_download_ownership_for_test(
|
|
ctx.state_dir.as_ref(),
|
|
current.path(),
|
|
"game",
|
|
&["game.eti"],
|
|
)
|
|
.await;
|
|
let (tx, mut rx) = crate::peer_event_channel();
|
|
|
|
handle_remove_downloaded_game_command(&ctx, &tx, "game".to_string()).await;
|
|
*ctx.game_dir.write().await = next.path().to_path_buf();
|
|
|
|
assert_eq!(*ctx.game_dir.read().await, next.path());
|
|
assert!(current.game_root().join("version.ini").is_file());
|
|
assert!(current.game_root().join("game.eti").is_file());
|
|
assert!(next.game_root().join("version.ini").is_file());
|
|
assert!(next.game_root().join("game.eti").is_file());
|
|
let terminal_event = tokio::time::timeout(Duration::from_secs(1), async {
|
|
loop {
|
|
let event = recv_event(&mut rx).await;
|
|
if matches!(
|
|
event,
|
|
PeerEvent::RemoveDownloadedGameFailed { ref id } if id == "game"
|
|
) {
|
|
break event;
|
|
}
|
|
assert!(!matches!(
|
|
event,
|
|
PeerEvent::RemoveDownloadedGameFinished { ref id } if id == "game"
|
|
));
|
|
}
|
|
})
|
|
.await
|
|
.expect("captured-root removal should report failure");
|
|
assert!(matches!(
|
|
terminal_event,
|
|
PeerEvent::RemoveDownloadedGameFailed { id } if id == "game"
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn active_operation_rejection_is_returned_in_set_game_dir_reply() {
|
|
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) = crate::peer_event_channel();
|
|
|
|
let error = handle_set_game_dir_command(&ctx, &tx, next.path().to_path_buf())
|
|
.await
|
|
.expect_err("active operation should reject SetGameDir");
|
|
|
|
assert!(error.contains("operations are active"));
|
|
assert_eq!(*ctx.game_dir.read().await, current.path());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn same_directory_alias_reply_uses_existing_canonical_root() {
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let current = TempDir::new("lanspread-handler-same-alias-target");
|
|
let aliases = TempDir::new("lanspread-handler-same-alias-parent");
|
|
let alias = aliases.path().join("games");
|
|
symlink(current.path(), &alias).expect("same-root alias should be created");
|
|
let current = std::fs::canonicalize(current.path()).expect("root should canonicalize");
|
|
let ctx = test_ctx(current.clone());
|
|
let (tx, _rx) = crate::peer_event_channel();
|
|
|
|
let accepted = handle_set_game_dir_command(&ctx, &tx, alias)
|
|
.await
|
|
.expect("same-root alias should be accepted");
|
|
|
|
assert_eq!(accepted, current);
|
|
assert_eq!(*ctx.game_dir.read().await, current);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn path_change_alias_reply_and_storage_use_canonical_target() {
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let current = TempDir::new("lanspread-handler-change-alias-current");
|
|
let next = TempDir::new("lanspread-handler-change-alias-target");
|
|
let aliases = TempDir::new("lanspread-handler-change-alias-parent");
|
|
let alias = aliases.path().join("games");
|
|
symlink(next.path(), &alias).expect("new-root alias should be created");
|
|
let current = std::fs::canonicalize(current.path()).expect("root should canonicalize");
|
|
let next = std::fs::canonicalize(next.path()).expect("new root should canonicalize");
|
|
let ctx = test_ctx(current);
|
|
let (tx, _rx) = crate::peer_event_channel();
|
|
|
|
let accepted = handle_set_game_dir_command(&ctx, &tx, alias.clone())
|
|
.await
|
|
.expect("new-root alias should be accepted");
|
|
|
|
assert_eq!(accepted, next);
|
|
assert_ne!(accepted, alias);
|
|
assert_eq!(*ctx.game_dir.read().await, next);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_directory_rejection_reply_preserves_current_root() {
|
|
let current = TempDir::new("lanspread-handler-invalid-dir-current");
|
|
let missing = current.path().join("missing");
|
|
let ctx = test_ctx(current.path().to_path_buf());
|
|
let (tx, mut rx) = crate::peer_event_channel();
|
|
|
|
let error = handle_set_game_dir_command(&ctx, &tx, missing)
|
|
.await
|
|
.expect_err("missing root should be rejected");
|
|
|
|
assert!(error.contains("failed to canonicalize game directory"));
|
|
assert_eq!(*ctx.game_dir.read().await, current.path());
|
|
assert_no_event(&mut rx).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn foreign_intent_for_absent_candidate_game_rejects_before_root_mutation() {
|
|
let current = TempDir::new("lanspread-handler-foreign-intent-current");
|
|
let candidate = TempDir::new("lanspread-handler-foreign-intent-candidate");
|
|
let current_path =
|
|
std::fs::canonicalize(current.path()).expect("current root should canonicalize");
|
|
let candidate_path =
|
|
std::fs::canonicalize(candidate.path()).expect("candidate root should canonicalize");
|
|
let ctx = test_ctx(current_path.clone());
|
|
let (tx, mut rx) = crate::peer_event_channel();
|
|
let published = summary("game", "20250101", Availability::Ready);
|
|
ctx.local_library
|
|
.write()
|
|
.await
|
|
.update_from_scan(
|
|
¤t_path,
|
|
HashMap::from([("game".to_string(), published.clone())]),
|
|
1,
|
|
)
|
|
.expect("initial library revision should be available");
|
|
*ctx.local_game_db.write().await = Some(GameDB::from(vec![game_from_summary(&published)]));
|
|
let before = ctx.local_library.read().await.clone();
|
|
|
|
let intent = InstallIntent::new(
|
|
¤t_path.join("orphan"),
|
|
"orphan",
|
|
InstallIntentState::Updating,
|
|
None,
|
|
)
|
|
.expect("old-root intent should be valid");
|
|
write_intent(ctx.state_dir.as_ref(), "orphan", &intent)
|
|
.expect("old-root intent should be persisted");
|
|
|
|
let error = handle_set_game_dir_command(&ctx, &tx, candidate_path.clone())
|
|
.await
|
|
.expect_err("foreign intent must reject the root switch");
|
|
|
|
assert!(error.contains("install intent state is unresolved"));
|
|
assert!(error.contains("different configured games"));
|
|
assert_eq!(*ctx.game_dir.read().await, current_path);
|
|
assert!(!candidate_path.join("orphan").exists());
|
|
assert!(intent_path(ctx.state_dir.as_ref(), "orphan").is_file());
|
|
assert!(!ctx.recovery_quarantine.is_blocked(¤t_path, "game"));
|
|
assert!(ctx.local_game_db.read().await.is_some());
|
|
let after = ctx.local_library.read().await;
|
|
assert_eq!(after.revision, before.revision);
|
|
assert_eq!(after.games, before.games);
|
|
drop(after);
|
|
assert_no_event(&mut rx).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn same_path_set_game_dir_drains_all_transfers_before_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) = crate::peer_event_channel();
|
|
let first = CancellationToken::new();
|
|
let second = CancellationToken::new();
|
|
*ctx.active_outbound_transfers.write().await = HashMap::from([
|
|
("game".to_string(), vec![(1, first.clone())]),
|
|
("other".to_string(), vec![(2, second.clone())]),
|
|
]);
|
|
|
|
let command_ctx = ctx.clone();
|
|
let command_tx = tx.clone();
|
|
let game_dir = temp.path().to_path_buf();
|
|
let command = tokio::spawn(async move {
|
|
handle_set_game_dir_command_with_drain_timeout(
|
|
&command_ctx,
|
|
&command_tx,
|
|
game_dir,
|
|
Duration::from_secs(1),
|
|
)
|
|
.await
|
|
});
|
|
|
|
tokio::time::timeout(Duration::from_secs(1), async {
|
|
first.cancelled().await;
|
|
second.cancelled().await;
|
|
})
|
|
.await
|
|
.expect("all outbound transfers should be cancelled");
|
|
assert!(temp.game_root().join(".version.ini.tmp").is_file());
|
|
|
|
ctx.active_outbound_transfers.write().await.remove("game");
|
|
tokio::task::yield_now().await;
|
|
assert!(!command.is_finished());
|
|
assert!(temp.game_root().join(".version.ini.tmp").is_file());
|
|
|
|
ctx.active_outbound_transfers.write().await.remove("other");
|
|
assert_eq!(
|
|
command
|
|
.await
|
|
.expect("SetGameDir task should not fail")
|
|
.expect("same root should be accepted"),
|
|
temp.path().to_path_buf()
|
|
);
|
|
|
|
assert!(ctx.active_outbound_transfers.read().await.is_empty());
|
|
assert!(!temp.game_root().join(".version.ini.tmp").exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn same_path_drain_timeout_rejection_is_returned_in_reply() {
|
|
let temp = TempDir::new("lanspread-handler-same-dir-timeout");
|
|
write_file(&temp.game_root().join(".version.ini.tmp"), b"tmp");
|
|
let ctx = test_ctx(temp.path().to_path_buf());
|
|
let (tx, mut rx) = crate::peer_event_channel();
|
|
let token = CancellationToken::new();
|
|
ctx.active_outbound_transfers
|
|
.write()
|
|
.await
|
|
.insert("game".to_string(), vec![(1, token.clone())]);
|
|
|
|
let error = handle_set_game_dir_command_with_drain_timeout(
|
|
&ctx,
|
|
&tx,
|
|
temp.path().to_path_buf(),
|
|
Duration::from_millis(1),
|
|
)
|
|
.await
|
|
.expect_err("drain timeout should reject SetGameDir");
|
|
|
|
assert!(error.contains("did not drain"));
|
|
assert!(token.is_cancelled());
|
|
assert_eq!(*ctx.game_dir.read().await, temp.path());
|
|
assert!(temp.game_root().join(".version.ini.tmp").is_file());
|
|
assert!(!ctx.recovery_quarantine.is_blocked(temp.path(), "game"));
|
|
assert_no_event(&mut rx).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn same_path_set_game_dir_skips_recovery_for_active_game() {
|
|
let temp = TempDir::new("lanspread-handler-same-dir-active");
|
|
write_file(&temp.game_root().join(".version.ini.tmp"), b"tmp");
|
|
let ctx = test_ctx(temp.path().to_path_buf());
|
|
ctx.active_operations
|
|
.write()
|
|
.await
|
|
.insert("game".to_string(), OperationKind::Downloading);
|
|
let (tx, _rx) = crate::peer_event_channel();
|
|
|
|
let error = handle_set_game_dir_command(&ctx, &tx, temp.path().to_path_buf())
|
|
.await
|
|
.expect_err("active game should reject SetGameDir refresh");
|
|
|
|
assert!(error.contains("operations are active"));
|
|
assert!(temp.game_root().join(".version.ini.tmp").is_file());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn path_changing_set_game_dir_drains_all_transfers_before_switch() {
|
|
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) = crate::peer_event_channel();
|
|
let first = CancellationToken::new();
|
|
let second = CancellationToken::new();
|
|
*ctx.active_outbound_transfers.write().await = HashMap::from([
|
|
("game".to_string(), vec![(1, first.clone())]),
|
|
("other".to_string(), vec![(2, second.clone())]),
|
|
]);
|
|
|
|
let command_ctx = ctx.clone();
|
|
let command_tx = tx.clone();
|
|
let next_path = next.path().to_path_buf();
|
|
let command = tokio::spawn(async move {
|
|
handle_set_game_dir_command_with_drain_timeout(
|
|
&command_ctx,
|
|
&command_tx,
|
|
next_path,
|
|
Duration::from_secs(1),
|
|
)
|
|
.await
|
|
});
|
|
|
|
tokio::time::timeout(Duration::from_secs(1), async {
|
|
first.cancelled().await;
|
|
second.cancelled().await;
|
|
})
|
|
.await
|
|
.expect("all outbound transfers should be cancelled");
|
|
assert_eq!(*ctx.game_dir.read().await, current.path());
|
|
assert!(next.game_root().join(".version.ini.tmp").is_file());
|
|
|
|
ctx.active_outbound_transfers.write().await.remove("game");
|
|
tokio::task::yield_now().await;
|
|
assert!(!command.is_finished());
|
|
assert_eq!(*ctx.game_dir.read().await, current.path());
|
|
|
|
ctx.active_outbound_transfers.write().await.remove("other");
|
|
assert_eq!(
|
|
command
|
|
.await
|
|
.expect("SetGameDir task should not fail")
|
|
.expect("new root should be accepted"),
|
|
next.path().to_path_buf()
|
|
);
|
|
|
|
assert_eq!(*ctx.game_dir.read().await, next.path());
|
|
assert!(!next.game_root().join(".version.ini.tmp").exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn path_change_drain_timeout_rejection_preserves_current_state() {
|
|
let current = TempDir::new("lanspread-handler-old-dir-timeout");
|
|
let next = TempDir::new("lanspread-handler-new-dir-timeout");
|
|
let root = current.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) = crate::peer_event_channel();
|
|
|
|
let catalog = ctx.catalog.catalog();
|
|
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;
|
|
let _ = recv_event(&mut rx).await;
|
|
assert!(
|
|
ctx.recovery_quarantine
|
|
.settle(current.path(), HashSet::from(["game".to_string()]))
|
|
);
|
|
let library_before = ctx.local_library.read().await.clone();
|
|
assert!(ctx.local_game_db.read().await.is_some());
|
|
|
|
write_file(&root.join(".version.ini.tmp"), b"old tmp");
|
|
write_file(&next.game_root().join(".version.ini.tmp"), b"new tmp");
|
|
let token = CancellationToken::new();
|
|
ctx.active_outbound_transfers
|
|
.write()
|
|
.await
|
|
.insert("game".to_string(), vec![(1, token.clone())]);
|
|
|
|
let error = handle_set_game_dir_command_with_drain_timeout(
|
|
&ctx,
|
|
&tx,
|
|
next.path().to_path_buf(),
|
|
Duration::from_millis(1),
|
|
)
|
|
.await
|
|
.expect_err("drain timeout should reject SetGameDir");
|
|
|
|
assert!(error.contains("did not drain"));
|
|
assert!(token.is_cancelled());
|
|
assert_eq!(*ctx.game_dir.read().await, current.path());
|
|
assert!(root.join(".version.ini.tmp").is_file());
|
|
assert!(next.game_root().join(".version.ini.tmp").is_file());
|
|
assert_eq!(
|
|
ctx.recovery_quarantine.failed_ids(current.path()),
|
|
HashSet::from(["game".to_string()])
|
|
);
|
|
let library_after = ctx.local_library.read().await;
|
|
assert_eq!(library_after.revision, library_before.revision);
|
|
assert_eq!(library_after.games, library_before.games);
|
|
drop(library_after);
|
|
assert!(ctx.local_game_db.read().await.is_some());
|
|
assert_no_event(&mut rx).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn path_change_fails_before_mutation_when_publication_revision_is_exhausted() {
|
|
let current = TempDir::new("lanspread-handler-revision-exhausted-current");
|
|
let next = TempDir::new("lanspread-handler-revision-exhausted-next");
|
|
let ctx = test_ctx(current.path().to_path_buf());
|
|
let (tx, mut rx) = crate::peer_event_channel();
|
|
let published = summary("game", "20250101", Availability::Ready);
|
|
ctx.local_library
|
|
.write()
|
|
.await
|
|
.update_from_scan(
|
|
current.path(),
|
|
HashMap::from([("game".to_string(), published.clone())]),
|
|
u64::MAX,
|
|
)
|
|
.expect("maximum source revision may be published once");
|
|
ctx.local_library.write().await.revision = u64::MAX;
|
|
*ctx.local_game_db.write().await = Some(GameDB::from(vec![game_from_summary(&published)]));
|
|
|
|
let error = handle_set_game_dir_command_with_drain_timeout(
|
|
&ctx,
|
|
&tx,
|
|
next.path().to_path_buf(),
|
|
Duration::from_secs(1),
|
|
)
|
|
.await
|
|
.expect_err("pre-mutation recovery failure should reject SetGameDir");
|
|
|
|
assert!(error.contains("failed to begin recovery"));
|
|
assert_eq!(*ctx.game_dir.read().await, current.path());
|
|
let library = ctx.local_library.read().await;
|
|
assert_eq!(library.revision, u64::MAX);
|
|
assert_eq!(library.games["game"], published);
|
|
drop(library);
|
|
assert!(ctx.local_game_db.read().await.is_some());
|
|
assert_no_event(&mut rx).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn accepted_quarantined_root_is_returned_in_canonical_reply() {
|
|
let current = TempDir::new("lanspread-handler-old-dir-recovery-failure");
|
|
let next = TempDir::new("lanspread-handler-new-dir-recovery-failure");
|
|
let next_root = next.game_root();
|
|
write_file(&next_root.join("version.ini"), b"20250101");
|
|
write_file(&next_root.join("game.eti"), b"archive");
|
|
std::fs::create_dir_all(next_root.join(".version.ini.tmp"))
|
|
.expect("invalid recovery scratch directory should be created");
|
|
let ctx = test_ctx(current.path().to_path_buf());
|
|
let (tx, mut rx) = crate::peer_event_channel();
|
|
|
|
assert_eq!(
|
|
handle_set_game_dir_command_with_drain_timeout(
|
|
&ctx,
|
|
&tx,
|
|
next.path().to_path_buf(),
|
|
Duration::from_secs(1),
|
|
)
|
|
.await
|
|
.expect("accepted root should be acknowledged despite quarantine"),
|
|
next.path().to_path_buf()
|
|
);
|
|
|
|
assert_eq!(*ctx.game_dir.read().await, next.path());
|
|
assert!(ctx.recovery_quarantine.is_blocked(next.path(), "game"));
|
|
let library = ctx.local_library.read().await;
|
|
let game = library
|
|
.games
|
|
.get("game")
|
|
.expect("failed game should be published in quarantined state");
|
|
assert!(!game.downloaded);
|
|
assert!(!game.installed);
|
|
drop(library);
|
|
|
|
assert_eq!(
|
|
begin_operation(
|
|
&ctx,
|
|
&tx,
|
|
&operation_target(next.path()),
|
|
OperationKind::Downloading,
|
|
)
|
|
.await,
|
|
BeginOperationResult::RecoveryBlocked
|
|
);
|
|
assert!(ctx.active_operations.read().await.is_empty());
|
|
|
|
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
|
|
panic!("expected cleared local library snapshot");
|
|
};
|
|
assert!(games.is_empty());
|
|
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
|
|
panic!("expected quarantined local library snapshot");
|
|
};
|
|
assert_eq!(games.len(), 1);
|
|
assert_eq!(games[0].id, "game");
|
|
assert!(!games[0].downloaded);
|
|
assert_eq!(games[0].availability, Availability::LocalOnly);
|
|
}
|
|
|
|
#[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) = crate::peer_event_channel();
|
|
let catalog = ctx.catalog.catalog();
|
|
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);
|
|
|
|
let initial_snapshot = {
|
|
let library = ctx.local_library.read().await;
|
|
crate::library::build_library_snapshot(
|
|
library.publication(ctx.catalog.catalog()),
|
|
&ctx.catalog,
|
|
)
|
|
.expect("initial catalog snapshot should build")
|
|
};
|
|
let initial_revision = initial_snapshot.revision;
|
|
assert_eq!(initial_snapshot.games.len(), 1);
|
|
|
|
assert_eq!(
|
|
handle_set_game_dir_command(&ctx, &tx, next.path().to_path_buf())
|
|
.await
|
|
.expect("equivalent new root should be accepted"),
|
|
next.path().to_path_buf()
|
|
);
|
|
|
|
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
|
|
panic!("expected cleared local library snapshot");
|
|
};
|
|
assert!(games.is_empty());
|
|
assert_local_update(recv_event(&mut rx).await, false, true);
|
|
|
|
let restored_snapshot = {
|
|
let library = ctx.local_library.read().await;
|
|
crate::library::build_library_snapshot(
|
|
library.publication(ctx.catalog.catalog()),
|
|
&ctx.catalog,
|
|
)
|
|
.expect("restored catalog snapshot should build")
|
|
};
|
|
assert!(restored_snapshot.revision >= initial_revision + 2);
|
|
assert_eq!(restored_snapshot.games.len(), 1);
|
|
assert_eq!(restored_snapshot.games[0].game_id, "game");
|
|
}
|
|
}
|