feat(peer)!: cut over to authenticated catalog sharing
Replace address-only trust and pushed peer state with installation identities, SPKI-pinned QUIC, candidate-only discovery, and bounded responder-owned protocol-8 pulls. The runtime now owns each network generation and all admitted work through shutdown. Add exact bundled content identities, reproducible manifest publishing, capability-confined downloads, streaming BLAKE3 verification, quarantine and retry, and crash-recoverable download and install transactions. Ship generated fixture catalogs and fail closed when production manifests are absent. The Tauri backend exposes durable sharing policy, redacted identity state, and attempt-keyed transfer snapshots. Frontend consumption follows in the next commit. Repository-wide test certificates and protocol-7 paths are removed. BREAKING CHANGE: peers must use protocol 8 and exact catalog content artifacts; protocol-7 frames and shared-certificate identities are no longer accepted. Test Plan: - `just test` -- passed on the completed stack (708 workspace tests) - `just clippy` -- passed on the completed stack - `just build` -- passed with fixture catalogs on the completed stack - `just catalog-check-production` -- failed closed because the external production manifest corpus is absent - `git diff --cached --check` -- passed
This commit is contained in:
128 files changed
+51759
-10784
No files matched your search
+3976
-1123
@@ -1,45 +1,67 @@
|
||||
//! Command handlers for peer commands.
|
||||
|
||||
use std::{
|
||||
collections::{HashSet, hash_map::Entry},
|
||||
collections::{HashMap, HashSet},
|
||||
fmt,
|
||||
future::Future,
|
||||
net::SocketAddr,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use lanspread_db::db::{GameDB, GameFileDescription};
|
||||
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
||||
use lanspread_db::{
|
||||
content_manifest::{CatalogContentManifest, ContentId},
|
||||
db::GameDB,
|
||||
};
|
||||
use lanspread_proto::PeerEndpoint;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::local_games::{rescan_local_game, scan_local_library};
|
||||
use crate::{
|
||||
DownloadAttemptKey,
|
||||
DownloadFailureReason,
|
||||
DownloadVerificationActivity,
|
||||
InstallOperation,
|
||||
PeerEvent,
|
||||
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,
|
||||
validate_protocol_v7_descriptions,
|
||||
},
|
||||
events,
|
||||
install,
|
||||
local_games::{
|
||||
LocalLibraryScan,
|
||||
game_from_summary,
|
||||
get_game_file_descriptions,
|
||||
local_dir_is_directory,
|
||||
local_download_matches_catalog,
|
||||
rescan_local_game,
|
||||
scan_local_library,
|
||||
rescan_local_game_with_recovery_failures,
|
||||
scan_local_library_with_recovery_failures,
|
||||
version_ini_is_regular_file,
|
||||
},
|
||||
network::{request_game_details_from_peer, send_library_delta},
|
||||
mark_launch_settings_applied,
|
||||
peer_db::PeerGameDB,
|
||||
remote_peer::ensure_peer_id_for_addr,
|
||||
services::{HandshakeCtx, perform_handshake_with_peer},
|
||||
stream_install::receive_streamed_install,
|
||||
quic_runtime::QuicConnector,
|
||||
scoped_blocking::scoped_blocking,
|
||||
services::{HandshakeCtx, ReservedCandidateHandshake},
|
||||
stream_install::{
|
||||
ReceiveStreamedInstallRequest,
|
||||
StreamInstallReceiveError,
|
||||
StreamInstallReceiveErrorKind,
|
||||
receive_streamed_install,
|
||||
},
|
||||
transfer_status::{DownloadAttemptReporter, DownloadAttemptStatus},
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
@@ -49,171 +71,140 @@ use crate::{
|
||||
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: &UnboundedSender<PeerEvent>,
|
||||
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: &UnboundedSender<PeerEvent>) {
|
||||
log::info!("ListGames command received");
|
||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, tx_notify_ui).await;
|
||||
}
|
||||
|
||||
/// Tries to serve a game from local files.
|
||||
async fn try_serve_local_game(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: &str,
|
||||
) -> bool {
|
||||
let game_dir = { ctx.game_dir.read().await.clone() };
|
||||
|
||||
let active_operations = ctx.active_operations.read().await;
|
||||
let catalog = ctx.catalog.read().await;
|
||||
if !local_download_matches_catalog(&game_dir, id, &active_operations, &catalog).await {
|
||||
return false;
|
||||
}
|
||||
drop(active_operations);
|
||||
drop(catalog);
|
||||
|
||||
match get_game_file_descriptions(id, &game_dir).await {
|
||||
Ok(file_descriptions) => {
|
||||
log::info!("Serving game {id} from local files");
|
||||
if let Err(e) = tx_notify_ui.send(PeerEvent::GotGameFiles {
|
||||
id: id.to_string(),
|
||||
file_descriptions,
|
||||
}) {
|
||||
log::error!("Failed to send GotGameFiles event: {e}");
|
||||
}
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to enumerate local file descriptions for {id}: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the `GetGame` command.
|
||||
pub(crate) async fn handle_get_game_command(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
source: GameDetailSource,
|
||||
) {
|
||||
if source.allows_local() && try_serve_local_game(ctx, tx_notify_ui, &id).await {
|
||||
return;
|
||||
}
|
||||
|
||||
log::info!("Requesting game from peers: {id}");
|
||||
let expected_version = catalog_expected_version(ctx, &id).await;
|
||||
let peers = {
|
||||
let peer_game_db = ctx.peer_game_db.read().await;
|
||||
source.select_peers(&peer_game_db, &id, expected_version.as_deref())
|
||||
};
|
||||
if peers.is_empty() {
|
||||
log::warn!("No peers have game {id}");
|
||||
if let Err(e) = tx_notify_ui.send(PeerEvent::NoPeersHaveGame { id: id.clone() }) {
|
||||
log::error!("Failed to send NoPeersHaveGame event: {e}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let peer_game_db = ctx.peer_game_db.clone();
|
||||
let tx_notify_ui = tx_notify_ui.clone();
|
||||
ctx.task_tracker.spawn(fetch_game_details_from_peers(
|
||||
peers,
|
||||
id,
|
||||
expected_version,
|
||||
peer_game_db,
|
||||
tx_notify_ui,
|
||||
|peer_addr, game_id, peer_game_db| async move {
|
||||
request_game_details_and_update(peer_addr, &game_id, peer_game_db).await
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) enum GameDetailSource {
|
||||
LocalOrPeers,
|
||||
LatestPeersOnly,
|
||||
}
|
||||
|
||||
impl GameDetailSource {
|
||||
fn allows_local(self) -> bool {
|
||||
matches!(self, Self::LocalOrPeers)
|
||||
}
|
||||
|
||||
fn select_peers(
|
||||
self,
|
||||
peer_game_db: &PeerGameDB,
|
||||
id: &str,
|
||||
expected_version: Option<&str>,
|
||||
) -> Vec<SocketAddr> {
|
||||
match self {
|
||||
Self::LocalOrPeers | Self::LatestPeersOnly => {
|
||||
peer_game_db.peers_with_expected_version(id, expected_version)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Requests game details from a peer and updates the peer game database.
|
||||
async fn request_game_details_and_update(
|
||||
peer_addr: SocketAddr,
|
||||
game_id: &str,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
) -> eyre::Result<Vec<GameFileDescription>> {
|
||||
let (file_descriptions, _) = request_game_details_from_peer(peer_addr, game_id).await?;
|
||||
let peer_id = ensure_peer_id_for_addr(&peer_game_db, peer_addr).await;
|
||||
|
||||
{
|
||||
let mut db = peer_game_db.write().await;
|
||||
db.update_peer_game_files(&peer_id, game_id, file_descriptions.clone());
|
||||
}
|
||||
|
||||
Ok(file_descriptions)
|
||||
}
|
||||
|
||||
async fn fetch_game_details_from_peers<F, Fut>(
|
||||
peers: Vec<SocketAddr>,
|
||||
id: String,
|
||||
expected_version: Option<String>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
mut fetch_details: F,
|
||||
) where
|
||||
F: FnMut(SocketAddr, String, Arc<RwLock<PeerGameDB>>) -> Fut + Send + 'static,
|
||||
Fut: Future<Output = eyre::Result<Vec<GameFileDescription>>> + Send,
|
||||
{
|
||||
let mut fetched_any = false;
|
||||
for peer_addr in peers {
|
||||
match fetch_details(peer_addr, id.clone(), peer_game_db.clone()).await {
|
||||
Ok(_) => {
|
||||
log::info!("Fetched game file list for {id} from peer {peer_addr}");
|
||||
fetched_any = true;
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to fetch game files for {id} from {peer_addr}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if fetched_any {
|
||||
let aggregated_files = {
|
||||
peer_game_db
|
||||
.read()
|
||||
.await
|
||||
.aggregated_game_files(&id, expected_version.as_deref())
|
||||
};
|
||||
|
||||
if let Err(e) = tx_notify_ui.send(PeerEvent::GotGameFiles {
|
||||
id: id.clone(),
|
||||
file_descriptions: aggregated_files,
|
||||
}) {
|
||||
log::error!("Failed to send GotGameFiles event: {e}");
|
||||
}
|
||||
} else {
|
||||
log::warn!("Failed to retrieve game files for {id} from any peer");
|
||||
if let Err(e) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id: id.clone() }) {
|
||||
log::error!("Failed to send DownloadGameFilesFailed event: {e}");
|
||||
}
|
||||
}
|
||||
events::emit_peer_game_list(&ctx.peer_game_db, tx_notify_ui).await;
|
||||
}
|
||||
|
||||
/// Handles the `DownloadGameFiles` command.
|
||||
@@ -225,144 +216,112 @@ pub async fn handle_download_game_files_command(
|
||||
install_after_download: bool,
|
||||
) {
|
||||
log::info!("Got PeerCommand::DownloadGameFiles");
|
||||
if !catalog_contains(ctx, &id).await {
|
||||
let attempt = DownloadAttemptKey::next(id.clone());
|
||||
if !catalog_contains(ctx, &id) {
|
||||
log::warn!("Ignoring download command for non-catalog game {id}");
|
||||
if let Err(send_err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id }) {
|
||||
log::error!("Failed to send DownloadGameFilesFailed event: {send_err}");
|
||||
}
|
||||
send_download_failed(
|
||||
tx_notify_ui,
|
||||
&attempt,
|
||||
DownloadFailureReason::OperationFailed,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let games_folder = { ctx.game_dir.read().await.clone() };
|
||||
let expected_version = catalog_expected_version(ctx, &id).await;
|
||||
|
||||
let raw_peer_manifests = ctx
|
||||
.peer_game_db
|
||||
.read()
|
||||
.await
|
||||
.expected_version_game_files_for(&id, expected_version.as_deref());
|
||||
let validation_game_id = id.clone();
|
||||
let raw_validation = tokio::task::spawn_blocking(move || {
|
||||
let mut valid = Vec::new();
|
||||
let mut rejected = Vec::new();
|
||||
for (peer_addr, descriptions) in raw_peer_manifests {
|
||||
match validate_protocol_v7_descriptions(&validation_game_id, descriptions) {
|
||||
Ok(descriptions) => valid.push((peer_addr, descriptions)),
|
||||
Err(error) => rejected.push((peer_addr, error.to_string())),
|
||||
}
|
||||
}
|
||||
(valid, rejected)
|
||||
})
|
||||
.await;
|
||||
let (peer_manifests, rejected_manifests) = match raw_validation {
|
||||
Ok(result) => result,
|
||||
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!("Peer manifest validation task failed for {id}: {error}");
|
||||
send_download_failed(tx_notify_ui, &id);
|
||||
log::error!("Failed to load catalog content manifest for {id}: {error}");
|
||||
send_download_failed(
|
||||
tx_notify_ui,
|
||||
&attempt,
|
||||
DownloadFailureReason::OperationFailed,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
for (peer_addr, error) in rejected_manifests {
|
||||
log::warn!("Ignoring invalid download manifest from {peer_addr} for {id}: {error}");
|
||||
}
|
||||
|
||||
// Use only complete, individually valid peer manifests for size consensus.
|
||||
let (validated_descriptions, peer_whitelist, file_peer_map) = {
|
||||
match ctx
|
||||
.peer_game_db
|
||||
.read()
|
||||
.await
|
||||
.validate_file_sizes_majority_from(&id, &peer_manifests)
|
||||
{
|
||||
Ok((files, peers, file_peer_map)) => {
|
||||
log::info!(
|
||||
"Majority validation: {} validated files, {} trusted peers for game {id}",
|
||||
files.len(),
|
||||
peers.len()
|
||||
);
|
||||
(files, peers, file_peer_map)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("File size majority validation failed for {id}: {e}");
|
||||
if let Err(send_err) =
|
||||
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id: id.clone() })
|
||||
{
|
||||
log::error!("Failed to send DownloadGameFilesFailed event: {send_err}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
let 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 = {
|
||||
let active_operations = ctx.active_operations.read().await;
|
||||
let catalog = ctx.catalog.read().await;
|
||||
local_download_matches_catalog(&games_folder, &id, &active_operations, &catalog).await
|
||||
if 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
|
||||
}
|
||||
};
|
||||
|
||||
if peer_whitelist.is_empty() {
|
||||
if local_dl_available {
|
||||
log::info!("Using locally downloaded files for game {id}; skipping peer transfer");
|
||||
if let Err(e) = tx_notify_ui.send(PeerEvent::DownloadGameFilesBegin { id: id.clone() })
|
||||
{
|
||||
log::error!("Failed to send DownloadGameFilesBegin event: {e}");
|
||||
}
|
||||
if let Err(e) =
|
||||
tx_notify_ui.send(PeerEvent::DownloadGameFilesFinished { id: id.clone() })
|
||||
{
|
||||
log::error!("Failed to send DownloadGameFilesFinished event: {e}");
|
||||
}
|
||||
if install_after_download {
|
||||
spawn_install_operation(ctx, tx_notify_ui, id.clone());
|
||||
}
|
||||
} else {
|
||||
log::error!("No trusted peers available after majority validation for game {id}");
|
||||
if let Err(send_err) =
|
||||
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id: id.clone() })
|
||||
{
|
||||
log::error!("Failed to send DownloadGameFilesFailed event: {send_err}");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if validated_descriptions.is_empty() {
|
||||
log::error!(
|
||||
"No validated file descriptions available to download game {id}; request metadata first"
|
||||
);
|
||||
if let Err(send_err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id }) {
|
||||
log::error!("Failed to send DownloadGameFilesFailed event: {send_err}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let catalog = ctx.catalog.read().await.clone();
|
||||
let manifest_games_folder = games_folder.clone();
|
||||
let manifest_game_id = id.clone();
|
||||
let manifest = tokio::task::spawn_blocking(move || {
|
||||
ValidatedDownloadManifest::from_protocol_v7(
|
||||
&manifest_games_folder,
|
||||
&manifest_game_id,
|
||||
validated_descriptions,
|
||||
&catalog,
|
||||
)
|
||||
})
|
||||
.await;
|
||||
let manifest = match manifest {
|
||||
Ok(Ok(manifest)) => manifest,
|
||||
Ok(Err(error)) => {
|
||||
log::error!("Rejected download manifest for {id}: {error}");
|
||||
send_download_failed(tx_notify_ui, &id);
|
||||
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::error!("Download manifest validation task failed for {id}: {error}");
|
||||
send_download_failed(tx_notify_ui, &id);
|
||||
log::warn!("Cannot download {id} while Local network sharing is unavailable: {error}");
|
||||
send_download_failed(
|
||||
tx_notify_ui,
|
||||
&attempt,
|
||||
DownloadFailureReason::OperationFailed,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match begin_operation(ctx, tx_notify_ui, &id, OperationKind::Downloading).await {
|
||||
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");
|
||||
@@ -370,128 +329,219 @@ pub async fn handle_download_game_files_command(
|
||||
}
|
||||
BeginOperationResult::DrainTimedOut => {
|
||||
log::error!("Timed out waiting for outbound transfers before downloading {id}");
|
||||
send_download_failed(tx_notify_ui, &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 active_operations = ctx.active_operations.clone();
|
||||
let active_downloads = ctx.active_downloads.clone();
|
||||
let tx_notify_ui_clone = tx_notify_ui.clone();
|
||||
let download_id = id.clone();
|
||||
let cancel_token = ctx.shutdown.child_token();
|
||||
let 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.active_downloads
|
||||
.write()
|
||||
.await
|
||||
.insert(id, cancel_token.clone());
|
||||
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());
|
||||
|
||||
ctx.task_tracker.spawn(async move {
|
||||
let download_state_guard = OperationGuard::download(
|
||||
download_id.clone(),
|
||||
active_operations,
|
||||
active_downloads,
|
||||
tx_notify_ui_clone.clone(),
|
||||
);
|
||||
|
||||
let result = download_game_files(
|
||||
manifest,
|
||||
ctx_clone.state_dir.as_ref(),
|
||||
peer_whitelist,
|
||||
file_peer_map,
|
||||
tx_notify_ui_clone.clone(),
|
||||
cancel_token.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let Some(prepared) =
|
||||
prepare_install_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await
|
||||
else {
|
||||
if let Err(err) = refresh_local_game_for_ending_operation(
|
||||
&ctx_clone,
|
||||
&tx_notify_ui_clone,
|
||||
&download_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to refresh local library after download: {err}");
|
||||
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))
|
||||
}
|
||||
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
||||
download_state_guard.disarm();
|
||||
send_download_finished(&tx_notify_ui_clone, &download_id);
|
||||
return;
|
||||
};
|
||||
|
||||
if install_after_download {
|
||||
if transition_download_to_install(
|
||||
&ctx_clone,
|
||||
&tx_notify_ui_clone,
|
||||
&download_id,
|
||||
prepared.operation_kind,
|
||||
)
|
||||
.await
|
||||
{
|
||||
clear_active_download(&ctx_clone, &download_id).await;
|
||||
send_download_finished(&tx_notify_ui_clone, &download_id);
|
||||
download_state_guard.disarm();
|
||||
run_started_install_operation(
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let Some(prepared) = prepare_install_operation(
|
||||
&ctx_clone,
|
||||
&tx_notify_ui_clone,
|
||||
download_id,
|
||||
prepared,
|
||||
&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;
|
||||
} else {
|
||||
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
||||
download_state_guard.disarm();
|
||||
send_download_finished(&tx_notify_ui_clone, &download_id);
|
||||
}
|
||||
} else {
|
||||
if let Err(err) = refresh_local_game_for_ending_operation(
|
||||
&ctx_clone,
|
||||
&tx_notify_ui_clone,
|
||||
&download_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to refresh local library after download: {err}");
|
||||
}
|
||||
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
||||
download_state_guard.disarm();
|
||||
send_download_finished(&tx_notify_ui_clone, &download_id);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if let Err(refresh_err) = refresh_local_game_for_ending_operation(
|
||||
&ctx_clone,
|
||||
&tx_notify_ui_clone,
|
||||
&download_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!(
|
||||
"Failed to refresh local library after download failure: {refresh_err}"
|
||||
);
|
||||
}
|
||||
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
||||
download_state_guard.disarm();
|
||||
let download_was_cancelled = cancel_token.is_cancelled();
|
||||
if download_was_cancelled {
|
||||
log::info!("Download cancelled for {download_id}: {e}");
|
||||
} else {
|
||||
log::error!("Download failed for {download_id}: {e}");
|
||||
}
|
||||
send_download_failed_unless_cancelled(
|
||||
&tx_notify_ui_clone,
|
||||
&download_id,
|
||||
download_was_cancelled,
|
||||
);
|
||||
}
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
fn finish_cached_download(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
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.
|
||||
@@ -500,61 +550,249 @@ pub async fn handle_install_game_command(
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
) {
|
||||
spawn_install_operation(ctx, tx_notify_ui, id);
|
||||
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: &UnboundedSender<PeerEvent>,
|
||||
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: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
settings: StreamInstallSettings,
|
||||
) {
|
||||
if !catalog_contains(ctx, &id).await {
|
||||
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, &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 game_root = games_folder.join(&id);
|
||||
if local_dir_is_directory(&game_root).await {
|
||||
log::warn!("Ignoring streamed install command for already-installed game {id}");
|
||||
send_download_failed(tx_notify_ui, &id);
|
||||
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;
|
||||
}
|
||||
|
||||
match begin_operation(ctx, tx_notify_ui, &id, OperationKind::Downloading).await {
|
||||
BeginOperationResult::Started => {}
|
||||
BeginOperationResult::AlreadyActive => {
|
||||
log::warn!("Operation for {id} already in progress; ignoring streamed install request");
|
||||
return;
|
||||
}
|
||||
BeginOperationResult::DrainTimedOut => {
|
||||
log::error!("Timed out waiting for outbound transfers before streamed install of {id}");
|
||||
send_download_failed(tx_notify_ui, &id);
|
||||
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 expected_version = catalog_expected_version(ctx, &id).await;
|
||||
let cancel_token = ctx.shutdown.child_token();
|
||||
ctx.active_downloads
|
||||
.write()
|
||||
.await
|
||||
.insert(id.clone(), cancel_token.clone());
|
||||
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();
|
||||
ctx.task_tracker.spawn(async move {
|
||||
run_stream_install_operation(
|
||||
ctx_clone,
|
||||
tx_notify_ui,
|
||||
id,
|
||||
game_root,
|
||||
expected_version,
|
||||
cancel_token,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
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: UnboundedSender<PeerEvent>,
|
||||
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: &UnboundedSender<PeerEvent>,
|
||||
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.
|
||||
@@ -563,10 +801,12 @@ pub async fn handle_uninstall_game_command(
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
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, id).await;
|
||||
run_uninstall_operation(&ctx, &tx_notify_ui, target).await;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -575,10 +815,12 @@ pub async fn handle_remove_downloaded_game_command(
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
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, id).await;
|
||||
run_remove_downloaded_operation(&ctx, &tx_notify_ui, target).await;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -587,301 +829,544 @@ pub async fn handle_cancel_download_command(
|
||||
_tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
) {
|
||||
let cancel_token = ctx.active_downloads.read().await.get(&id).cloned();
|
||||
let Some(cancel_token) = cancel_token else {
|
||||
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}");
|
||||
cancel_token.cancel();
|
||||
signal.cancel_silently();
|
||||
}
|
||||
|
||||
async fn run_stream_install_operation(
|
||||
ctx: Ctx,
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
game_root: PathBuf,
|
||||
expected_version: Option<String>,
|
||||
cancel_token: CancellationToken,
|
||||
) {
|
||||
let download_guard = OperationGuard::download(
|
||||
id.clone(),
|
||||
ctx.active_operations.clone(),
|
||||
ctx.active_downloads.clone(),
|
||||
tx_notify_ui.clone(),
|
||||
);
|
||||
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();
|
||||
|
||||
events::send(
|
||||
&tx_notify_ui,
|
||||
PeerEvent::DownloadGameFilesBegin { id: id.clone() },
|
||||
);
|
||||
|
||||
let peer_addrs =
|
||||
match select_stream_install_peers(&ctx, &id, expected_version.as_deref(), &cancel_token)
|
||||
.await
|
||||
{
|
||||
Ok(peers) => peers,
|
||||
Err(err) => {
|
||||
let download_was_cancelled = cancel_token.is_cancelled();
|
||||
if download_was_cancelled {
|
||||
log::info!("Streamed install preflight cancelled for {id}: {err}");
|
||||
} else {
|
||||
log::error!("Streamed install preflight failed for {id}: {err}");
|
||||
}
|
||||
finish_failed_stream_download(
|
||||
&ctx,
|
||||
&tx_notify_ui,
|
||||
&id,
|
||||
download_guard,
|
||||
download_was_cancelled,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match receive_streamed_install_from_peers(
|
||||
let Some((sources, download_guard)) = select_stream_install_sources_or_finish(
|
||||
&ctx,
|
||||
&tx_notify_ui,
|
||||
&id,
|
||||
&game_root,
|
||||
&peer_addrs,
|
||||
&cancel_token,
|
||||
&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
|
||||
{
|
||||
clear_active_download(&ctx, &id).await;
|
||||
send_download_finished(&tx_notify_ui, &id);
|
||||
download_guard.disarm();
|
||||
commit_streamed_install(&ctx, &tx_notify_ui, id, transaction).await;
|
||||
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().await {
|
||||
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, &id, download_guard, false).await;
|
||||
}
|
||||
Err(err) => {
|
||||
let download_was_cancelled = cancel_token.is_cancelled();
|
||||
if download_was_cancelled {
|
||||
log::info!("Streamed install download cancelled for {id}: {err}");
|
||||
} else {
|
||||
log::error!("Streamed install download failed for {id}: {err}");
|
||||
}
|
||||
finish_failed_stream_download(
|
||||
&ctx,
|
||||
&tx_notify_ui,
|
||||
&id,
|
||||
&target,
|
||||
download_guard,
|
||||
download_was_cancelled,
|
||||
&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 receive_streamed_install_from_peers(
|
||||
async fn finish_stream_receive_error(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
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 UnboundedSender<PeerEvent>,
|
||||
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 UnboundedSender<PeerEvent>,
|
||||
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,
|
||||
peer_addrs: &[SocketAddr],
|
||||
cancel_token: &CancellationToken,
|
||||
) -> eyre::Result<install::StreamedInstallTransaction> {
|
||||
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;
|
||||
for &peer_addr in peer_addrs {
|
||||
let mut retry_invalid_source = false;
|
||||
let content_id = manifest.content_id();
|
||||
for source in sources {
|
||||
if cancel_token.is_cancelled() {
|
||||
eyre::bail!("streamed install for {id} was 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 =
|
||||
install::begin_streamed_install(game_root, ctx.state_dir.as_ref(), id).await?;
|
||||
let receive_result = receive_streamed_install(
|
||||
peer_addr,
|
||||
let transaction = begin_stream_receive_attempt(
|
||||
&game_root,
|
||||
ctx.state_dir.as_ref(),
|
||||
id,
|
||||
transaction.staging_dir(),
|
||||
tx_notify_ui.clone(),
|
||||
cancel_token.clone(),
|
||||
)
|
||||
&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) => {
|
||||
if let Err(rollback_err) = transaction.rollback().await {
|
||||
log::error!("Failed to roll back streamed install for {id}: {rollback_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")
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if cancel_token.is_cancelled() {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"Streamed install attempt from {peer_addr} failed for {id}; trying another peer if available: {err}"
|
||||
);
|
||||
last_receive_error = Some(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_receive_error.unwrap_or_else(|| {
|
||||
eyre::eyre!("streamed install download failed for {id}: no peer attempts were made")
|
||||
}))
|
||||
}
|
||||
|
||||
async fn select_stream_install_peers(
|
||||
ctx: &Ctx,
|
||||
id: &str,
|
||||
expected_version: Option<&str>,
|
||||
cancel_token: &CancellationToken,
|
||||
) -> eyre::Result<Vec<SocketAddr>> {
|
||||
let mut metadata_peers = {
|
||||
ctx.peer_game_db
|
||||
.read()
|
||||
.await
|
||||
.peers_with_expected_version(id, expected_version)
|
||||
};
|
||||
metadata_peers.sort();
|
||||
if metadata_peers.is_empty() {
|
||||
eyre::bail!("no peers have game {id}");
|
||||
}
|
||||
|
||||
refresh_stream_install_file_details(ctx, id, &metadata_peers, cancel_token).await?;
|
||||
|
||||
let mut peers = match ctx
|
||||
.peer_game_db
|
||||
.read()
|
||||
.await
|
||||
.validate_file_sizes_majority(id, expected_version)
|
||||
{
|
||||
Ok((validated_files, peer_whitelist, _)) if !validated_files.is_empty() => peer_whitelist,
|
||||
Ok(_) => {
|
||||
eyre::bail!("no trusted peers available for streamed install of {id}");
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(err.wrap_err(format!(
|
||||
"file size majority validation failed for streamed install {id}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
peers.sort();
|
||||
if peers.is_empty() {
|
||||
eyre::bail!("no peer selected for streamed install of {id}");
|
||||
}
|
||||
|
||||
Ok(peers)
|
||||
}
|
||||
|
||||
async fn refresh_stream_install_file_details(
|
||||
ctx: &Ctx,
|
||||
id: &str,
|
||||
peers: &[SocketAddr],
|
||||
cancel_token: &CancellationToken,
|
||||
) -> eyre::Result<()> {
|
||||
let mut fetched_any = false;
|
||||
for &peer_addr in peers {
|
||||
if cancel_token.is_cancelled() {
|
||||
eyre::bail!("streamed install for {id} was cancelled");
|
||||
}
|
||||
|
||||
match request_game_details_and_update(peer_addr, id, ctx.peer_game_db.clone()).await {
|
||||
Ok(_) => {
|
||||
log::info!("Fetched streamed-install file list for {id} from peer {peer_addr}");
|
||||
fetched_any = true;
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!(
|
||||
"Failed to fetch streamed-install files for {id} from {peer_addr}: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !fetched_any {
|
||||
eyre::bail!("failed to retrieve game files for {id} from any peer");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Err(exhausted_stream_receive_error(id, last_receive_error))
|
||||
}
|
||||
|
||||
async fn finish_failed_stream_download(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: &str,
|
||||
target: &OperationTarget,
|
||||
guard: OperationGuard,
|
||||
cancelled: bool,
|
||||
status: &DownloadAttemptStatus,
|
||||
direct_reason: Option<DownloadFailureReason>,
|
||||
) {
|
||||
if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, id).await {
|
||||
log::error!("Failed to refresh local library after streamed install failure: {err}");
|
||||
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);
|
||||
}
|
||||
end_download_operation(ctx, tx_notify_ui, id).await;
|
||||
guard.disarm();
|
||||
send_download_failed_unless_cancelled(tx_notify_ui, id, cancelled);
|
||||
}
|
||||
|
||||
async fn commit_streamed_install(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
struct StreamInstallCommit {
|
||||
ctx: Ctx,
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
target: OperationTarget,
|
||||
transaction: install::StreamedInstallTransaction,
|
||||
) {
|
||||
let operation_guard = OperationGuard::new(
|
||||
id.clone(),
|
||||
ctx.active_operations.clone(),
|
||||
tx_notify_ui.clone(),
|
||||
);
|
||||
manifest: Arc<CatalogContentManifest>,
|
||||
cancel_token: CancellationToken,
|
||||
operation_guard: OperationGuard,
|
||||
download_attempt: DownloadAttemptStatus,
|
||||
}
|
||||
|
||||
match transaction.commit().await {
|
||||
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(()) => {
|
||||
if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
|
||||
// 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
|
||||
{
|
||||
log::error!("Failed to refresh local library after streamed install: {err}");
|
||||
events::send(
|
||||
&tx_notify_ui,
|
||||
PeerEvent::InstallGameFinished { id: id.clone() },
|
||||
);
|
||||
} else {
|
||||
events::send(
|
||||
&tx_notify_ui,
|
||||
PeerEvent::InstallGameFailed { id: id.clone() },
|
||||
);
|
||||
}
|
||||
end_operation(ctx, tx_notify_ui, &id).await;
|
||||
operation_guard.disarm();
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
PeerEvent::InstallGameFinished { id: id.clone() },
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Streamed install commit failed for {id}: {err}");
|
||||
if let Err(refresh_err) =
|
||||
refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
|
||||
{
|
||||
log::error!(
|
||||
"Failed to refresh local library after streamed install commit failure: {refresh_err}"
|
||||
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() },
|
||||
);
|
||||
}
|
||||
end_operation(ctx, tx_notify_ui, &id).await;
|
||||
operation_guard.disarm();
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
PeerEvent::InstallGameFailed { id: id.clone() },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
|
||||
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: &UnboundedSender<PeerEvent>,
|
||||
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, id).await;
|
||||
run_install_operation(&ctx, &tx_notify_ui, target).await;
|
||||
});
|
||||
}
|
||||
|
||||
async fn run_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
|
||||
let Some(prepared) = prepare_install_operation(ctx, tx_notify_ui, &id).await else {
|
||||
async fn run_install_operation(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
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, &id, prepared.operation_kind).await {
|
||||
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");
|
||||
@@ -895,13 +1380,62 @@ async fn run_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEve
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
run_started_install_operation(ctx, tx_notify_ui, id, prepared).await;
|
||||
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 {
|
||||
game_root: PathBuf,
|
||||
operation: InstallOperation,
|
||||
operation_kind: OperationKind,
|
||||
}
|
||||
@@ -909,14 +1443,34 @@ struct PreparedInstallOperation {
|
||||
async fn prepare_install_operation(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: &str,
|
||||
target: &OperationTarget,
|
||||
) -> Option<PreparedInstallOperation> {
|
||||
if !catalog_contains(ctx, id).await {
|
||||
let id = target.game_id();
|
||||
if !catalog_contains(ctx, id) {
|
||||
log::warn!("Ignoring install command for non-catalog game {id}");
|
||||
return None;
|
||||
}
|
||||
|
||||
let game_root = { ctx.game_dir.read().await.join(id) };
|
||||
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(
|
||||
@@ -938,63 +1492,103 @@ async fn prepare_install_operation(
|
||||
};
|
||||
|
||||
Some(PreparedInstallOperation {
|
||||
game_root,
|
||||
operation,
|
||||
operation_kind,
|
||||
})
|
||||
}
|
||||
|
||||
async fn revalidate_install_operation(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
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: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
target: OperationTarget,
|
||||
prepared: PreparedInstallOperation,
|
||||
operation_guard: OperationGuard,
|
||||
cancel_token: CancellationToken,
|
||||
) {
|
||||
let PreparedInstallOperation {
|
||||
game_root,
|
||||
operation,
|
||||
..
|
||||
} = prepared;
|
||||
|
||||
let operation_guard = OperationGuard::new(
|
||||
id.clone(),
|
||||
ctx.active_operations.clone(),
|
||||
tx_notify_ui.clone(),
|
||||
);
|
||||
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()).await
|
||||
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()).await
|
||||
install::update(
|
||||
&game_root,
|
||||
state_dir,
|
||||
&id,
|
||||
ctx.unpacker.clone(),
|
||||
cancel_token.clone(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
|
||||
{
|
||||
log::error!("Failed to refresh local library after install: {err}");
|
||||
}
|
||||
end_operation(ctx, tx_notify_ui, &id).await;
|
||||
operation_guard.disarm();
|
||||
events::send(
|
||||
if settle_and_end_operation(
|
||||
ctx,
|
||||
tx_notify_ui,
|
||||
PeerEvent::InstallGameFinished { id: id.clone() },
|
||||
);
|
||||
&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}");
|
||||
if let Err(refresh_err) =
|
||||
refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
|
||||
{
|
||||
log::error!("Failed to refresh local library after install failure: {refresh_err}");
|
||||
}
|
||||
end_operation(ctx, tx_notify_ui, &id).await;
|
||||
operation_guard.disarm();
|
||||
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() },
|
||||
@@ -1003,13 +1597,18 @@ async fn run_started_install_operation(
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
|
||||
if !catalog_contains(ctx, &id).await {
|
||||
async fn run_uninstall_operation(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
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, &id, OperationKind::Uninstalling).await {
|
||||
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");
|
||||
@@ -1023,40 +1622,70 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerE
|
||||
);
|
||||
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 = { ctx.game_dir.read().await.join(&id) };
|
||||
let operation_guard = OperationGuard::new(
|
||||
id.clone(),
|
||||
ctx.active_operations.clone(),
|
||||
tx_notify_ui.clone(),
|
||||
);
|
||||
let result = install::uninstall(&game_root, ctx.state_dir.as_ref(), &id).await;
|
||||
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 let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
|
||||
{
|
||||
log::error!("Failed to refresh local library after uninstall: {err}");
|
||||
}
|
||||
end_operation(ctx, tx_notify_ui, &id).await;
|
||||
operation_guard.disarm();
|
||||
events::send(
|
||||
if settle_and_end_operation(
|
||||
ctx,
|
||||
tx_notify_ui,
|
||||
PeerEvent::UninstallGameFinished { id: id.clone() },
|
||||
);
|
||||
&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}");
|
||||
if let Err(refresh_err) =
|
||||
refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
|
||||
{
|
||||
log::error!(
|
||||
"Failed to refresh local library after uninstall failure: {refresh_err}"
|
||||
);
|
||||
}
|
||||
end_operation(ctx, tx_notify_ui, &id).await;
|
||||
operation_guard.disarm();
|
||||
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() },
|
||||
@@ -1068,14 +1697,15 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerE
|
||||
async fn run_remove_downloaded_operation(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
target: OperationTarget,
|
||||
) {
|
||||
if !catalog_contains(ctx, &id).await {
|
||||
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, &id, OperationKind::RemovingDownload).await {
|
||||
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");
|
||||
@@ -1089,40 +1719,71 @@ async fn run_remove_downloaded_operation(
|
||||
);
|
||||
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 game_dir = { ctx.game_dir.read().await.clone() };
|
||||
let operation_guard = OperationGuard::new(
|
||||
id.clone(),
|
||||
ctx.active_operations.clone(),
|
||||
tx_notify_ui.clone(),
|
||||
);
|
||||
let result = remove_downloaded_payload(&game_dir, ctx.state_dir.as_ref(), &id).await;
|
||||
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 let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
|
||||
{
|
||||
log::error!("Failed to refresh local library after downloaded-file removal: {err}");
|
||||
}
|
||||
end_operation(ctx, tx_notify_ui, &id).await;
|
||||
operation_guard.disarm();
|
||||
events::send(
|
||||
if settle_and_end_operation(
|
||||
ctx,
|
||||
tx_notify_ui,
|
||||
PeerEvent::RemoveDownloadedGameFinished { id: id.clone() },
|
||||
);
|
||||
&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}");
|
||||
if let Err(refresh_err) =
|
||||
refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
|
||||
{
|
||||
log::error!(
|
||||
"Failed to refresh local library after downloaded-file removal failure: {refresh_err}"
|
||||
);
|
||||
}
|
||||
end_operation(ctx, tx_notify_ui, &id).await;
|
||||
operation_guard.disarm();
|
||||
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() },
|
||||
@@ -1135,19 +1796,22 @@ async fn run_remove_downloaded_operation(
|
||||
enum BeginOperationResult {
|
||||
Started,
|
||||
AlreadyActive,
|
||||
GameDirChanged,
|
||||
DrainTimedOut,
|
||||
RecoveryBlocked,
|
||||
PublicationFailed,
|
||||
}
|
||||
|
||||
async fn begin_operation(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: &str,
|
||||
target: &OperationTarget,
|
||||
operation: OperationKind,
|
||||
) -> BeginOperationResult {
|
||||
begin_operation_with_drain_timeout(
|
||||
ctx,
|
||||
tx_notify_ui,
|
||||
id,
|
||||
target,
|
||||
operation,
|
||||
OUTBOUND_TRANSFER_DRAIN_TIMEOUT,
|
||||
)
|
||||
@@ -1157,18 +1821,62 @@ async fn begin_operation(
|
||||
async fn begin_operation_with_drain_timeout(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: &str,
|
||||
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;
|
||||
match active_operations.entry(id.to_string()) {
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(operation);
|
||||
true
|
||||
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);
|
||||
}
|
||||
Entry::Occupied(_) => false,
|
||||
true
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1176,12 +1884,30 @@ async fn begin_operation_with_drain_timeout(
|
||||
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, id, drain_timeout).await
|
||||
&& !cancel_and_wait_for_outbound_transfers(
|
||||
ctx,
|
||||
OutboundTransferScope::Game(target.game_id()),
|
||||
drain_timeout,
|
||||
)
|
||||
.await
|
||||
{
|
||||
end_operation(ctx, tx_notify_ui, id).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;
|
||||
}
|
||||
|
||||
@@ -1189,23 +1915,65 @@ async fn begin_operation_with_drain_timeout(
|
||||
}
|
||||
|
||||
fn operation_requires_outbound_drain(operation: OperationKind) -> bool {
|
||||
operation == OperationKind::Updating || operation == OperationKind::RemovingDownload
|
||||
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,
|
||||
id: &str,
|
||||
scope: OutboundTransferScope<'_>,
|
||||
drain_timeout: Duration,
|
||||
) -> bool {
|
||||
let mut tokens_to_cancel = Vec::new();
|
||||
{
|
||||
let tokens_to_cancel = {
|
||||
let active = ctx.active_outbound_transfers.read().await;
|
||||
if let Some(transfers) = active.get(id) {
|
||||
for (_, token) in transfers {
|
||||
tokens_to_cancel.push(token.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
outbound_transfer_tokens(&active, scope)
|
||||
};
|
||||
for token in tokens_to_cancel {
|
||||
token.cancel();
|
||||
}
|
||||
@@ -1214,7 +1982,7 @@ async fn cancel_and_wait_for_outbound_transfers(
|
||||
loop {
|
||||
let count = {
|
||||
let active = ctx.active_outbound_transfers.read().await;
|
||||
active.get(id).map_or(0, Vec::len)
|
||||
outbound_transfer_count(&active, scope)
|
||||
};
|
||||
if count == 0 {
|
||||
break;
|
||||
@@ -1228,10 +1996,11 @@ async fn cancel_and_wait_for_outbound_transfers(
|
||||
if !drained {
|
||||
let count = {
|
||||
let active = ctx.active_outbound_transfers.read().await;
|
||||
active.get(id).map_or(0, Vec::len)
|
||||
outbound_transfer_count(&active, scope)
|
||||
};
|
||||
let scope = scope.description();
|
||||
log::error!(
|
||||
"Timed out after {drain_timeout:?} waiting for {count} outbound transfer(s) to drain for {id}"
|
||||
"Timed out after {drain_timeout:?} waiting for {count} outbound transfer(s) to drain {scope}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1279,104 +2048,297 @@ async fn end_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id:
|
||||
}
|
||||
}
|
||||
|
||||
async fn clear_active_download(ctx: &Ctx, id: &str) {
|
||||
ctx.active_downloads.write().await.remove(id);
|
||||
}
|
||||
|
||||
fn send_download_finished(tx_notify_ui: &UnboundedSender<PeerEvent>, id: &str) {
|
||||
if let Err(err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFinished { id: id.into() }) {
|
||||
log::error!("Failed to send DownloadGameFilesFinished event: {err}");
|
||||
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: &UnboundedSender<PeerEvent>, id: &str) {
|
||||
if let Err(err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id: id.into() }) {
|
||||
log::error!("Failed to send DownloadGameFilesFailed event: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
fn send_download_failed_unless_cancelled(
|
||||
fn send_download_failed(
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: &str,
|
||||
cancelled: bool,
|
||||
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: &UnboundedSender<PeerEvent>,
|
||||
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: &UnboundedSender<PeerEvent>,
|
||||
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: &UnboundedSender<PeerEvent>,
|
||||
target: &OperationTarget,
|
||||
guard: OperationGuard,
|
||||
label: &str,
|
||||
) -> bool {
|
||||
if cancelled {
|
||||
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;
|
||||
}
|
||||
|
||||
send_download_failed(tx_notify_ui, id);
|
||||
// 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 end_download_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: &str) {
|
||||
end_operation(ctx, tx_notify_ui, id).await;
|
||||
clear_active_download(ctx, id).await;
|
||||
async fn settle_and_end_download(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
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 catalog_contains(ctx: &Ctx, id: &str) -> bool {
|
||||
ctx.catalog.read().await.contains(id)
|
||||
async fn finish_successful_download_after_refresh(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
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 catalog_expected_version(ctx: &Ctx, id: &str) -> Option<String> {
|
||||
ctx.catalog
|
||||
.read()
|
||||
.await
|
||||
.expected_version(id)
|
||||
.map(ToOwned::to_owned)
|
||||
async fn finish_failed_download_after_refresh(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
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: &UnboundedSender<PeerEvent>,
|
||||
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: &UnboundedSender<PeerEvent>,
|
||||
game_dir: PathBuf,
|
||||
) {
|
||||
let current_game_dir = ctx.game_dir.read().await.clone();
|
||||
if current_game_dir == game_dir {
|
||||
log::info!(
|
||||
"Game directory {} unchanged; refreshing without recovery",
|
||||
) -> 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: &UnboundedSender<PeerEvent>,
|
||||
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()
|
||||
);
|
||||
let tx_notify_ui = tx_notify_ui.clone();
|
||||
let ctx_clone = ctx.clone();
|
||||
ctx.task_tracker.spawn(async move {
|
||||
if let Err(err) = refresh_local_library(&ctx_clone, &tx_notify_ui).await {
|
||||
log::error!("Failed to refresh local game database: {err}");
|
||||
}
|
||||
});
|
||||
return;
|
||||
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() {
|
||||
log::warn!(
|
||||
"Rejecting game directory change to {} while operations are active for: {}",
|
||||
game_dir.display(),
|
||||
active_ids.into_iter().collect::<Vec<_>>().join(", ")
|
||||
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(", ")
|
||||
);
|
||||
return;
|
||||
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());
|
||||
|
||||
let tx_notify_ui = tx_notify_ui.clone();
|
||||
let ctx_clone = ctx.clone();
|
||||
|
||||
ctx.task_tracker.spawn(async move {
|
||||
match load_local_library_with_policy(
|
||||
&ctx_clone,
|
||||
&tx_notify_ui,
|
||||
LocalLibraryEventPolicy::ForceSnapshot,
|
||||
)
|
||||
match load_local_library_with_policy(ctx, tx_notify_ui, LocalLibraryEventPolicy::ForceSnapshot)
|
||||
.await
|
||||
{
|
||||
Ok(()) => log::info!("Local game database loaded successfully"),
|
||||
Err(e) => {
|
||||
log::error!("Failed to load local game database: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
{
|
||||
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.
|
||||
@@ -1393,25 +2355,32 @@ async fn load_local_library_with_policy(
|
||||
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();
|
||||
install::recover_on_startup(&game_dir, ctx.state_dir.as_ref(), &active_ids).await?;
|
||||
let recovery_report =
|
||||
install::recover_on_startup(&game_dir, ctx.state_dir.as_ref(), &active_ids).await?;
|
||||
drop(active_operations);
|
||||
scan_and_announce_local_library(ctx, tx_notify_ui, &game_dir, event_policy).await
|
||||
}
|
||||
|
||||
async fn refresh_local_library(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
) -> eyre::Result<()> {
|
||||
let game_dir = { ctx.game_dir.read().await.clone() };
|
||||
scan_and_announce_local_library(
|
||||
ctx,
|
||||
tx_notify_ui,
|
||||
&game_dir,
|
||||
LocalLibraryEventPolicy::OnChange,
|
||||
)
|
||||
.await
|
||||
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(
|
||||
@@ -1419,11 +2388,22 @@ async fn scan_and_announce_local_library(
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
game_dir: &Path,
|
||||
event_policy: LocalLibraryEventPolicy,
|
||||
recovery_failed_ids: &HashSet<String>,
|
||||
) -> eyre::Result<()> {
|
||||
let catalog = ctx.catalog.read().await.clone();
|
||||
let scan = scan_local_library(game_dir, ctx.state_dir.as_ref(), &catalog).await?;
|
||||
update_and_announce_games_with_policy(ctx, tx_notify_ui, scan, event_policy, None).await;
|
||||
Ok(())
|
||||
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
|
||||
@@ -1431,20 +2411,32 @@ async fn scan_and_announce_local_library(
|
||||
async fn refresh_local_game_for_ending_operation(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: &str,
|
||||
target: &OperationTarget,
|
||||
) -> eyre::Result<()> {
|
||||
let game_dir = { ctx.game_dir.read().await.clone() };
|
||||
let catalog = ctx.catalog.read().await.clone();
|
||||
let scan = rescan_local_game(&game_dir, ctx.state_dir.as_ref(), &catalog, id).await?;
|
||||
update_and_announce_games_with_policy(
|
||||
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(id),
|
||||
Some(target.game_id()),
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
.await
|
||||
{
|
||||
LocalLibraryPublication::Published => Ok(()),
|
||||
LocalLibraryPublication::Rejected => {
|
||||
eyre::bail!("local game refresh became obsolete before publication")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -1453,6 +2445,12 @@ enum LocalLibraryEventPolicy {
|
||||
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()
|
||||
}
|
||||
@@ -1467,16 +2465,41 @@ pub async fn handle_get_peer_count_command(ctx: &Ctx, tx_notify_ui: &UnboundedSe
|
||||
pub async fn handle_connect_peer_command(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
addr: SocketAddr,
|
||||
endpoint: PeerEndpoint,
|
||||
) {
|
||||
log::info!("Direct connect command received for {addr}");
|
||||
let handshake_ctx = HandshakeCtx::from_ctx(ctx, tx_notify_ui);
|
||||
|
||||
ctx.task_tracker.spawn(async move {
|
||||
if let Err(err) = perform_handshake_with_peer(handshake_ctx, addr, None).await {
|
||||
log::warn!("Failed direct connect to {addr}: {err}");
|
||||
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);
|
||||
}
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -1489,7 +2512,7 @@ pub async fn update_and_announce_games(
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
scan: LocalLibraryScan,
|
||||
) {
|
||||
update_and_announce_games_with_policy(
|
||||
let _ = update_and_announce_games_with_policy(
|
||||
ctx,
|
||||
tx_notify_ui,
|
||||
scan,
|
||||
@@ -1505,13 +2528,27 @@ async fn update_and_announce_games_with_policy(
|
||||
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);
|
||||
@@ -1523,19 +2560,51 @@ async fn update_and_announce_games_with_policy(
|
||||
game_db = GameDB::from(summaries.values().map(game_from_summary).collect());
|
||||
}
|
||||
|
||||
let delta = {
|
||||
let mut library_guard = ctx.local_library.write().await;
|
||||
library_guard.update_from_scan(summaries, revision)
|
||||
// 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 delta.is_some() || event_policy == LocalLibraryEventPolicy::ForceSnapshot {
|
||||
if published_revision.is_some() || event_policy == LocalLibraryEventPolicy::ForceSnapshot {
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
PeerEvent::LocalLibraryChanged {
|
||||
@@ -1546,57 +2615,85 @@ async fn update_and_announce_games_with_policy(
|
||||
log::debug!("Skipping unchanged local library event");
|
||||
}
|
||||
|
||||
let Some(delta) = delta else {
|
||||
return;
|
||||
let Some(published_revision) = published_revision else {
|
||||
return LocalLibraryPublication::Published;
|
||||
};
|
||||
|
||||
let peer_targets = {
|
||||
let db = ctx.peer_game_db.read().await;
|
||||
db.peer_identities()
|
||||
.into_iter()
|
||||
.map(|(_peer_id, addr)| addr)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
ctx.state_sync.publish_library_revision(published_revision);
|
||||
|
||||
for peer_addr in peer_targets {
|
||||
let delta = delta.clone();
|
||||
let peer_id = ctx.peer_id.as_ref().clone();
|
||||
ctx.task_tracker.spawn(async move {
|
||||
if let Err(e) = send_library_delta(peer_addr, &peer_id, delta).await {
|
||||
log::warn!("Failed to send library delta to {peer_addr}: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
LocalLibraryPublication::Published
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
collections::{BTreeMap, HashMap},
|
||||
net::SocketAddr,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use lanspread_db::db::GameCatalog;
|
||||
use lanspread_proto::{Availability, GameSummary};
|
||||
use tokio::sync::mpsc;
|
||||
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, mpsc, oneshot};
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
ActiveOperation,
|
||||
ActiveOperationKind,
|
||||
CallToPlayLocalAction,
|
||||
CallToPlayLocalIntent,
|
||||
UnpackFuture,
|
||||
Unpacker,
|
||||
test_support::TempDir,
|
||||
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) -> UnpackFuture<'a> {
|
||||
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(())
|
||||
@@ -1611,45 +2708,817 @@ mod tests {
|
||||
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");
|
||||
Ctx::new(
|
||||
let recovery_root = game_dir.clone();
|
||||
let ctx = Ctx::new(
|
||||
Arc::new(RwLock::new(PeerGameDB::new())),
|
||||
"peer".to_string(),
|
||||
Arc::new(PeerIdentity::generate().expect("test identity should generate")),
|
||||
game_dir,
|
||||
state_dir,
|
||||
Arc::new(FakeUnpacker),
|
||||
CancellationToken::new(),
|
||||
TaskTracker::new(),
|
||||
Arc::new(RwLock::new(GameCatalog::from_ids(["game".to_string()]))),
|
||||
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: &UnboundedSender<PeerEvent>,
|
||||
) -> (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_error_does_not_emit_failed_event() {
|
||||
fn cancelled_download_owner_does_not_emit_failed_event() {
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let status = DownloadAttemptStatus::new(
|
||||
DownloadAttemptKey::next("game".to_owned()),
|
||||
CancellationToken::new(),
|
||||
tx,
|
||||
);
|
||||
|
||||
let emitted = send_download_failed_unless_cancelled(&tx, "game", true);
|
||||
assert!(status.signal().cancel_silently());
|
||||
status.close_source_admission();
|
||||
status.clear_activity();
|
||||
|
||||
assert!(!emitted);
|
||||
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) = mpsc::unbounded_channel();
|
||||
let attempt = DownloadAttemptKey::next("game".to_owned());
|
||||
|
||||
let emitted = send_download_failed_unless_cancelled(&tx, "game", false);
|
||||
send_download_failed(&tx, &attempt, DownloadFailureReason::OperationFailed);
|
||||
|
||||
assert!(emitted);
|
||||
assert!(matches!(
|
||||
rx.try_recv(),
|
||||
Ok(PeerEvent::DownloadGameFilesFailed { id }) if id == "game"
|
||||
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) = mpsc::unbounded_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) = mpsc::unbounded_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) = mpsc::unbounded_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) = mpsc::unbounded_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) = mpsc::unbounded_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) = mpsc::unbounded_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) = mpsc::unbounded_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) = mpsc::unbounded_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) = mpsc::unbounded_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::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) = mpsc::unbounded_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) = mpsc::unbounded_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) = mpsc::unbounded_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) = mpsc::unbounded_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 mpsc::UnboundedReceiver<PeerEvent>) -> PeerEvent {
|
||||
tokio::time::timeout(Duration::from_secs(1), rx.recv())
|
||||
.await
|
||||
@@ -1666,32 +3535,65 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn drain_events(rx: &mut mpsc::UnboundedReceiver<PeerEvent>) -> 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 summary(id: &str, version: &str, availability: Availability) -> GameSummary {
|
||||
GameSummary {
|
||||
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()),
|
||||
manifest_hash: 7,
|
||||
availability,
|
||||
}
|
||||
}
|
||||
|
||||
fn file_desc(game_id: &str, relative_path: &str, size: u64) -> GameFileDescription {
|
||||
GameFileDescription {
|
||||
game_id: game_id.to_string(),
|
||||
relative_path: relative_path.to_string(),
|
||||
is_dir: false,
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_local_update(event: PeerEvent, installed: bool, downloaded: bool) {
|
||||
let _ = local_update_game(event, installed, downloaded);
|
||||
}
|
||||
@@ -1728,180 +3630,89 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_source_selects_expected_ready_peer_manifest() {
|
||||
let old_addr = addr(12_000);
|
||||
let new_addr = addr(12_001);
|
||||
let local_only_addr = addr(12_002);
|
||||
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();
|
||||
db.upsert_peer("old".to_string(), old_addr);
|
||||
db.upsert_peer("new".to_string(), new_addr);
|
||||
db.upsert_peer("local-only".to_string(), local_only_addr);
|
||||
db.update_peer_games(
|
||||
&"old".to_string(),
|
||||
vec![summary("game", "20240101", Availability::Ready)],
|
||||
);
|
||||
db.update_peer_games(
|
||||
&"new".to_string(),
|
||||
vec![summary("game", "20250101", Availability::Ready)],
|
||||
);
|
||||
db.update_peer_games(
|
||||
&"local-only".to_string(),
|
||||
vec![summary("game", "20990101", Availability::LocalOnly)],
|
||||
);
|
||||
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!(
|
||||
GameDetailSource::LatestPeersOnly.select_peers(&db, "game", Some("20250101")),
|
||||
vec![new_addr]
|
||||
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]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_fetch_emits_fresh_manifest_from_expected_peer() {
|
||||
let old_addr = addr(12_010);
|
||||
let new_addr = addr(12_011);
|
||||
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
|
||||
{
|
||||
let mut db = peer_game_db.write().await;
|
||||
db.upsert_peer("old".to_string(), old_addr);
|
||||
db.upsert_peer("new".to_string(), new_addr);
|
||||
db.update_peer_games(
|
||||
&"old".to_string(),
|
||||
vec![summary("game", "20240101", Availability::Ready)],
|
||||
);
|
||||
db.update_peer_games(
|
||||
&"new".to_string(),
|
||||
vec![summary("game", "20250101", Availability::Ready)],
|
||||
);
|
||||
}
|
||||
let peers = {
|
||||
let db = peer_game_db.read().await;
|
||||
GameDetailSource::LatestPeersOnly.select_peers(&db, "game", Some("20250101"))
|
||||
};
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let fetched_peers = Arc::new(Mutex::new(Vec::new()));
|
||||
#[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
|
||||
);
|
||||
}
|
||||
|
||||
fetch_game_details_from_peers(
|
||||
peers,
|
||||
"game".to_string(),
|
||||
Some("20250101".to_string()),
|
||||
peer_game_db.clone(),
|
||||
#[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) = mpsc::unbounded_channel();
|
||||
let status = DownloadAttemptStatus::new(
|
||||
DownloadAttemptKey::next("game".to_owned()),
|
||||
CancellationToken::new(),
|
||||
tx,
|
||||
{
|
||||
let fetched_peers = fetched_peers.clone();
|
||||
move |peer_addr, game_id, peer_game_db| {
|
||||
let fetched_peers = fetched_peers.clone();
|
||||
async move {
|
||||
fetched_peers
|
||||
.lock()
|
||||
.expect("fetched peer list should not be poisoned")
|
||||
.push(peer_addr);
|
||||
let files = vec![
|
||||
file_desc(&game_id, "game/version.ini", 8),
|
||||
file_desc(&game_id, "game/new.eti", 11),
|
||||
];
|
||||
peer_game_db.write().await.update_peer_game_files(
|
||||
&"new".to_string(),
|
||||
&game_id,
|
||||
files.clone(),
|
||||
);
|
||||
Ok(files)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
*fetched_peers
|
||||
.lock()
|
||||
.expect("fetched peer list should not be poisoned"),
|
||||
vec![new_addr]
|
||||
);
|
||||
let PeerEvent::GotGameFiles {
|
||||
id,
|
||||
file_descriptions,
|
||||
} = recv_event(&mut rx).await
|
||||
else {
|
||||
panic!("expected GotGameFiles");
|
||||
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!(id, "game");
|
||||
assert!(
|
||||
file_descriptions
|
||||
.iter()
|
||||
.any(|desc| desc.relative_path == "game/new.eti" && desc.size == 11),
|
||||
"expected-version peer manifest should be emitted to the download path"
|
||||
);
|
||||
|
||||
assert_eq!(error.reason, Some(DownloadFailureReason::OperationFailed));
|
||||
assert!(retry_invalid_source);
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_peer_detail_fetch_emits_terminal_download_failure() {
|
||||
let first_addr = addr(12_020);
|
||||
let second_addr = addr(12_021);
|
||||
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let fetched_peers = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
fetch_game_details_from_peers(
|
||||
vec![first_addr, second_addr],
|
||||
"game".to_string(),
|
||||
Some("20250101".to_string()),
|
||||
peer_game_db,
|
||||
tx.clone(),
|
||||
{
|
||||
let fetched_peers = fetched_peers.clone();
|
||||
move |peer_addr, _game_id, _peer_game_db| {
|
||||
let fetched_peers = fetched_peers.clone();
|
||||
async move {
|
||||
fetched_peers
|
||||
.lock()
|
||||
.expect("fetched peer list should not be poisoned")
|
||||
.push(peer_addr);
|
||||
Err::<Vec<GameFileDescription>, _>(eyre::eyre!("detail fetch failed"))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
*fetched_peers
|
||||
.lock()
|
||||
.expect("fetched peer list should not be poisoned"),
|
||||
vec![first_addr, second_addr]
|
||||
);
|
||||
assert!(matches!(
|
||||
recv_event(&mut rx).await,
|
||||
PeerEvent::DownloadGameFilesFailed { id } if id == "game"
|
||||
));
|
||||
assert_no_event(&mut rx).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_request_skips_local_manifest_even_when_download_exists() {
|
||||
let temp = TempDir::new("lanspread-handler-expected-peer");
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join("version.ini"), b"20240101");
|
||||
write_file(&root.join("game.eti"), b"old archive");
|
||||
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
handle_get_game_command(
|
||||
&ctx,
|
||||
&tx,
|
||||
"game".to_string(),
|
||||
GameDetailSource::LatestPeersOnly,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(
|
||||
recv_event(&mut rx).await,
|
||||
PeerEvent::NoPeersHaveGame { id } if id == "game"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_library_scan_hides_active_game_state() {
|
||||
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");
|
||||
@@ -1909,12 +3720,13 @@ mod tests {
|
||||
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let catalog = ctx.catalog.read().await.clone();
|
||||
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)
|
||||
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 {
|
||||
@@ -1923,15 +3735,27 @@ mod tests {
|
||||
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)
|
||||
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 {
|
||||
@@ -1941,6 +3765,204 @@ mod tests {
|
||||
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) = mpsc::unbounded_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) = mpsc::unbounded_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) = mpsc::unbounded_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]
|
||||
@@ -1952,9 +3974,10 @@ mod tests {
|
||||
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let target = operation_target(temp.path());
|
||||
|
||||
assert_eq!(
|
||||
begin_operation(&ctx, &tx, "game", OperationKind::Updating).await,
|
||||
begin_operation(&ctx, &tx, &target, OperationKind::Updating).await,
|
||||
BeginOperationResult::Started
|
||||
);
|
||||
assert_active_update(
|
||||
@@ -1966,6 +3989,66 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[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) = mpsc::unbounded_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");
|
||||
@@ -1975,6 +4058,13 @@ mod tests {
|
||||
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_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()
|
||||
@@ -1985,7 +4075,7 @@ mod tests {
|
||||
begin_operation_with_drain_timeout(
|
||||
&ctx,
|
||||
&tx,
|
||||
"game",
|
||||
&target,
|
||||
OperationKind::Updating,
|
||||
Duration::from_millis(1),
|
||||
)
|
||||
@@ -1994,6 +4084,10 @@ mod tests {
|
||||
);
|
||||
|
||||
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 {
|
||||
@@ -2001,11 +4095,96 @@ mod tests {
|
||||
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) = mpsc::unbounded_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]
|
||||
@@ -2017,15 +4196,15 @@ mod tests {
|
||||
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let catalog = ctx.catalog.read().await.clone();
|
||||
let catalog = ctx.catalog.catalog();
|
||||
|
||||
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &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)
|
||||
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;
|
||||
@@ -2043,19 +4222,24 @@ mod tests {
|
||||
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let catalog = ctx.catalog.read().await.clone();
|
||||
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
|
||||
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, "game".to_string()).await;
|
||||
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,
|
||||
@@ -2074,7 +4258,7 @@ mod tests {
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
||||
run_install_operation(&ctx, &tx, operation_target(temp.path())).await;
|
||||
|
||||
assert_active_update(
|
||||
recv_event(&mut rx).await,
|
||||
@@ -2089,6 +4273,108 @@ mod tests {
|
||||
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) = mpsc::unbounded_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) = mpsc::unbounded_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");
|
||||
@@ -2097,16 +4383,14 @@ mod tests {
|
||||
write_file(&root.join("game.eti"), b"archive");
|
||||
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (prepare_tx, _prepare_rx) = mpsc::unbounded_channel();
|
||||
let (download_status, _download_cancel) = register_test_download(&ctx, &prepare_tx).await;
|
||||
ctx.active_operations
|
||||
.write()
|
||||
.await
|
||||
.insert("game".to_string(), OperationKind::Downloading);
|
||||
ctx.active_downloads
|
||||
.write()
|
||||
.await
|
||||
.insert("game".to_string(), CancellationToken::new());
|
||||
let (prepare_tx, _prepare_rx) = mpsc::unbounded_channel();
|
||||
let prepared = prepare_install_operation(&ctx, &prepare_tx, "game")
|
||||
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;
|
||||
@@ -2115,13 +4399,26 @@ mod tests {
|
||||
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, "game").await;
|
||||
run_started_install_operation(&ctx, &tx, "game".to_string(), prepared).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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2148,16 +4445,12 @@ mod tests {
|
||||
async fn cancel_download_command_only_cancels_active_token() {
|
||||
let temp = TempDir::new("lanspread-handler-cancel-download");
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let cancel = CancellationToken::new();
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let (_status, cancel) = register_test_download(&ctx, &tx).await;
|
||||
ctx.active_operations
|
||||
.write()
|
||||
.await
|
||||
.insert("game".to_string(), OperationKind::Downloading);
|
||||
ctx.active_downloads
|
||||
.write()
|
||||
.await
|
||||
.insert("game".to_string(), cancel.clone());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
handle_cancel_download_command(&ctx, &tx, "game".to_string()).await;
|
||||
|
||||
@@ -2181,7 +4474,7 @@ mod tests {
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
||||
run_install_operation(&ctx, &tx, operation_target(temp.path())).await;
|
||||
|
||||
assert_active_update(
|
||||
recv_event(&mut rx).await,
|
||||
@@ -2206,7 +4499,7 @@ mod tests {
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
||||
run_install_operation(&ctx, &tx, operation_target(temp.path())).await;
|
||||
assert_active_update(
|
||||
recv_event(&mut rx).await,
|
||||
&active_update("game", ActiveOperationKind::Installing),
|
||||
@@ -2222,7 +4515,11 @@ mod tests {
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
write_file(&root.join("game.eti"), b"new archive");
|
||||
|
||||
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
||||
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),
|
||||
@@ -2235,7 +4532,11 @@ mod tests {
|
||||
PeerEvent::InstallGameFinished { id } if id == "game"
|
||||
));
|
||||
|
||||
run_uninstall_operation(&ctx, &tx, "game".to_string()).await;
|
||||
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),
|
||||
@@ -2261,7 +4562,7 @@ mod tests {
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
run_uninstall_operation(&ctx, &tx, "game".to_string()).await;
|
||||
run_uninstall_operation(&ctx, &tx, operation_target(temp.path())).await;
|
||||
|
||||
assert_active_update(
|
||||
recv_event(&mut rx).await,
|
||||
@@ -2292,23 +4593,23 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let catalog = ctx.catalog.read().await.clone();
|
||||
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
|
||||
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, "game".to_string()).await;
|
||||
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),
|
||||
);
|
||||
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
|
||||
panic!("expected LocalLibraryChanged");
|
||||
};
|
||||
assert!(games.is_empty());
|
||||
assert_active_update(recv_event(&mut rx).await, &[]);
|
||||
assert!(matches!(
|
||||
recv_event(&mut rx).await,
|
||||
@@ -2321,7 +4622,134 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn path_changing_set_game_dir_is_rejected_while_operations_are_active() {
|
||||
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) = mpsc::unbounded_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) = mpsc::unbounded_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) = mpsc::unbounded_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());
|
||||
@@ -2331,40 +4759,435 @@ mod tests {
|
||||
.insert("game".to_string(), OperationKind::Downloading);
|
||||
let (tx, _rx) = mpsc::unbounded_channel();
|
||||
|
||||
handle_set_game_dir_command(&ctx, &tx, next.path().to_path_buf()).await;
|
||||
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_path_set_game_dir_refreshes_without_recovery() {
|
||||
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) = mpsc::unbounded_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) = mpsc::unbounded_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) = mpsc::unbounded_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) = mpsc::unbounded_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) = mpsc::unbounded_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())]),
|
||||
]);
|
||||
|
||||
handle_set_game_dir_command(&ctx, &tx, temp.path().to_path_buf()).await;
|
||||
ctx.task_tracker.close();
|
||||
ctx.task_tracker.wait().await;
|
||||
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) = mpsc::unbounded_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) = mpsc::unbounded_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_runs_recovery() {
|
||||
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) = mpsc::unbounded_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())]),
|
||||
]);
|
||||
|
||||
handle_set_game_dir_command(&ctx, &tx, next.path().to_path_buf()).await;
|
||||
ctx.task_tracker.close();
|
||||
ctx.task_tracker.wait().await;
|
||||
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) = mpsc::unbounded_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) = mpsc::unbounded_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) = mpsc::unbounded_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");
|
||||
@@ -2376,17 +5199,47 @@ mod tests {
|
||||
|
||||
let ctx = test_ctx(current.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let catalog = ctx.catalog.read().await.clone();
|
||||
let scan = scan_local_library(current.path(), ctx.state_dir.as_ref(), &catalog)
|
||||
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);
|
||||
|
||||
handle_set_game_dir_command(&ctx, &tx, next.path().to_path_buf()).await;
|
||||
ctx.task_tracker.close();
|
||||
ctx.task_tracker.wait().await;
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user