feat(peer): coordinate outbound transfers with local game mutations
Updating or removing a local game rewrites its on-disk files. Peers that were mid-download of that game would keep streaming bytes from files that are being deleted or replaced, handing them a corrupt or stale copy. There was also no authoritative notion of which game version a peer should serve or accept, so a peer could serve whatever happened to be on disk and downloaders could aggregate files from peers running mismatched versions. This introduces a reader-writer coordination scheme between outbound file transfers (readers) and local mutation operations (writers), and gates both serving and downloading on an authoritative game catalog version. Reader-writer coordination: - Track active outbound transfers per game in a shared `OutboundTransfers` map of (id, CancellationToken), threaded through `Ctx`/`PeerCtx` and registered by a `TransferGuard` in the stream service. The guard is registered *before* the serve-eligibility check to close a TOCTOU window where a writer could miss an in-flight reader. - `stream_file_bytes` now honors a cancellation token at every await point (file read, network send, stream close) via `tokio::select!`, so a transfer aborts promptly instead of hanging on a stalled receiver. - `begin_operation` marks a game active first, then cancels its outbound transfers and waits for the count to reach zero before any Updating/RemovingDownload work touches the filesystem. - Active games are now hidden from library snapshots entirely while an operation is in flight, instead of freezing their last announced state, so peers stop discovering a game that is being mutated. Authoritative version catalog: - Replace the `HashSet<String>` catalog with `GameCatalog`, mapping each game id to its expected version (from the bundled game.db / ETI data). - Serving requires the local `version.ini` to match the catalog version (`local_download_matches_catalog`); peer selection, file aggregation, and majority size validation all filter on the expected version (`peers_with_expected_version`, `aggregated_game_files`, and friends). User-visible changes: - The GUI shows confirmation dialogs before Update and Remove, and surfaces a sharing-status indicator on game cards and the detail modal. - A new `OutboundTransferCountChanged` event lets the UI reflect live outbound transfer activity. Test Plan: - just test - just frontend-test - just clippy
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
|
||||
use lanspread_db::db::GameCatalog;
|
||||
use lanspread_proto::{Hello, HelloAck, PROTOCOL_VERSION};
|
||||
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
||||
|
||||
@@ -22,6 +23,7 @@ pub(crate) struct HandshakeCtx {
|
||||
local_library: Arc<RwLock<LocalLibraryState>>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
}
|
||||
|
||||
impl HandshakeCtx {
|
||||
@@ -32,6 +34,7 @@ impl HandshakeCtx {
|
||||
local_library: ctx.local_library.clone(),
|
||||
peer_game_db: ctx.peer_game_db.clone(),
|
||||
tx_notify_ui: tx_notify_ui.clone(),
|
||||
catalog: ctx.catalog.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +45,7 @@ impl HandshakeCtx {
|
||||
local_library: ctx.local_library.clone(),
|
||||
peer_game_db: ctx.peer_game_db.clone(),
|
||||
tx_notify_ui: ctx.tx_notify_ui.clone(),
|
||||
catalog: ctx.catalog.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,7 +125,7 @@ pub(crate) async fn perform_handshake_with_peer(
|
||||
.await;
|
||||
|
||||
after_peer_library_recorded(&ctx, upsert, record_addr).await;
|
||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.tx_notify_ui).await;
|
||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -156,7 +160,7 @@ pub(super) async fn accept_inbound_hello(
|
||||
.await;
|
||||
|
||||
after_peer_library_recorded(&handshake_ctx, upsert, addr).await;
|
||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.tx_notify_ui).await;
|
||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await;
|
||||
|
||||
build_hello_ack(ctx).await
|
||||
}
|
||||
@@ -201,12 +205,13 @@ async fn after_peer_library_recorded(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
collections::HashMap,
|
||||
net::SocketAddr,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use lanspread_db::db::GameCatalog;
|
||||
use lanspread_proto::{Availability, GameSummary, Hello, LibrarySnapshot, PROTOCOL_VERSION};
|
||||
use tokio::sync::{RwLock, mpsc};
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
@@ -242,6 +247,7 @@ mod tests {
|
||||
local_library: Arc::new(RwLock::new(LocalLibraryState::empty())),
|
||||
peer_game_db,
|
||||
tx_notify_ui,
|
||||
catalog: Arc::new(RwLock::new(GameCatalog::empty())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,6 +307,8 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn inbound_hello_applies_remote_library_snapshot() {
|
||||
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
|
||||
let mut catalog = GameCatalog::empty();
|
||||
catalog.insert("remote-game".to_string(), Some("20250101".to_string()));
|
||||
let ctx = Ctx::new(
|
||||
peer_game_db.clone(),
|
||||
"local-peer".to_string(),
|
||||
@@ -309,7 +317,8 @@ mod tests {
|
||||
Arc::new(NoopUnpacker),
|
||||
CancellationToken::new(),
|
||||
TaskTracker::new(),
|
||||
Arc::new(RwLock::new(HashSet::new())),
|
||||
Arc::new(RwLock::new(catalog)),
|
||||
Arc::new(RwLock::new(HashMap::new())),
|
||||
);
|
||||
*ctx.local_peer_addr.write().await = Some(addr([127, 0, 0, 1], 4000));
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||
|
||||
use lanspread_db::db::GameCatalog;
|
||||
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
|
||||
@@ -18,6 +19,7 @@ use crate::{
|
||||
pub async fn run_ping_service(
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
active_operations: Arc<RwLock<HashMap<String, OperationKind>>>,
|
||||
active_downloads: Arc<RwLock<HashMap<String, CancellationToken>>>,
|
||||
shutdown: CancellationToken,
|
||||
@@ -40,6 +42,7 @@ pub async fn run_ping_service(
|
||||
|
||||
ping_idle_peers(
|
||||
&peer_game_db,
|
||||
&catalog,
|
||||
&active_operations,
|
||||
&active_downloads,
|
||||
&tx_notify_ui,
|
||||
@@ -50,6 +53,7 @@ pub async fn run_ping_service(
|
||||
|
||||
prune_stale_peers(
|
||||
&peer_game_db,
|
||||
&catalog,
|
||||
&active_operations,
|
||||
&active_downloads,
|
||||
&tx_notify_ui,
|
||||
@@ -60,6 +64,7 @@ pub async fn run_ping_service(
|
||||
|
||||
async fn ping_idle_peers(
|
||||
peer_game_db: &Arc<RwLock<PeerGameDB>>,
|
||||
catalog: &Arc<RwLock<GameCatalog>>,
|
||||
active_operations: &Arc<RwLock<HashMap<String, OperationKind>>>,
|
||||
active_downloads: &Arc<RwLock<HashMap<String, CancellationToken>>>,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
@@ -75,6 +80,7 @@ async fn ping_idle_peers(
|
||||
|
||||
let tx_notify_ui = tx_notify_ui.clone();
|
||||
let peer_game_db = peer_game_db.clone();
|
||||
let catalog = catalog.clone();
|
||||
let active_operations = active_operations.clone();
|
||||
let active_downloads = active_downloads.clone();
|
||||
let shutdown = shutdown.clone();
|
||||
@@ -93,6 +99,7 @@ async fn ping_idle_peers(
|
||||
log::warn!("Peer {peer_addr} failed ping check");
|
||||
remove_peer_and_refresh(
|
||||
&peer_game_db,
|
||||
&catalog,
|
||||
&active_operations,
|
||||
&active_downloads,
|
||||
&tx_notify_ui,
|
||||
@@ -105,6 +112,7 @@ async fn ping_idle_peers(
|
||||
log::error!("Failed to ping peer {peer_addr}: {err}");
|
||||
remove_peer_and_refresh(
|
||||
&peer_game_db,
|
||||
&catalog,
|
||||
&active_operations,
|
||||
&active_downloads,
|
||||
&tx_notify_ui,
|
||||
@@ -120,6 +128,7 @@ async fn ping_idle_peers(
|
||||
|
||||
async fn prune_stale_peers(
|
||||
peer_game_db: &Arc<RwLock<PeerGameDB>>,
|
||||
catalog: &Arc<RwLock<GameCatalog>>,
|
||||
active_operations: &Arc<RwLock<HashMap<String, OperationKind>>>,
|
||||
active_downloads: &Arc<RwLock<HashMap<String, CancellationToken>>>,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
@@ -137,7 +146,7 @@ async fn prune_stale_peers(
|
||||
}
|
||||
|
||||
if removed_any {
|
||||
events::emit_peer_game_list(peer_game_db, tx_notify_ui).await;
|
||||
events::emit_peer_game_list(peer_game_db, catalog, tx_notify_ui).await;
|
||||
handle_active_downloads_without_peers(
|
||||
peer_game_db,
|
||||
active_operations,
|
||||
@@ -150,6 +159,7 @@ async fn prune_stale_peers(
|
||||
|
||||
async fn remove_peer_and_refresh(
|
||||
peer_game_db: &Arc<RwLock<PeerGameDB>>,
|
||||
catalog: &Arc<RwLock<GameCatalog>>,
|
||||
active_operations: &Arc<RwLock<HashMap<String, OperationKind>>>,
|
||||
active_downloads: &Arc<RwLock<HashMap<String, CancellationToken>>>,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
@@ -157,7 +167,7 @@ async fn remove_peer_and_refresh(
|
||||
log_label: &str,
|
||||
) {
|
||||
if remove_peer(peer_game_db, tx_notify_ui, peer_id, log_label).await {
|
||||
events::emit_peer_game_list(peer_game_db, tx_notify_ui).await;
|
||||
events::emit_peer_game_list(peer_game_db, catalog, tx_notify_ui).await;
|
||||
handle_active_downloads_without_peers(
|
||||
peer_game_db,
|
||||
active_operations,
|
||||
|
||||
@@ -336,12 +336,12 @@ fn should_ignore_game_child(name: &str) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use lanspread_db::db::GameCatalog;
|
||||
use notify::{
|
||||
EventKind,
|
||||
event::{AccessKind, AccessMode},
|
||||
@@ -373,7 +373,7 @@ mod tests {
|
||||
std::fs::write(path, bytes).expect("file should be written");
|
||||
}
|
||||
|
||||
fn test_ctx(game_dir: PathBuf, catalog: HashSet<String>) -> Ctx {
|
||||
fn test_ctx(game_dir: PathBuf, catalog: GameCatalog) -> Ctx {
|
||||
Ctx::new(
|
||||
Arc::new(RwLock::new(PeerGameDB::new())),
|
||||
"peer".to_string(),
|
||||
@@ -383,6 +383,7 @@ mod tests {
|
||||
CancellationToken::new(),
|
||||
TaskTracker::new(),
|
||||
Arc::new(RwLock::new(catalog)),
|
||||
Arc::new(RwLock::new(std::collections::HashMap::new())),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -445,7 +446,7 @@ mod tests {
|
||||
let temp = TempDir::new("lanspread-local-monitor");
|
||||
let ctx = test_ctx(
|
||||
temp.path().to_path_buf(),
|
||||
HashSet::from(["game".to_string()]),
|
||||
GameCatalog::from_ids(["game".to_string()]),
|
||||
);
|
||||
ctx.active_operations
|
||||
.write()
|
||||
@@ -480,7 +481,7 @@ mod tests {
|
||||
write_file(&temp.path().join("game").join("version.ini"), b"20250101");
|
||||
let ctx = test_ctx(
|
||||
temp.path().to_path_buf(),
|
||||
HashSet::from(["game".to_string()]),
|
||||
GameCatalog::from_ids(["game".to_string()]),
|
||||
);
|
||||
let gate = RescanGate::default();
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
@@ -515,7 +516,7 @@ mod tests {
|
||||
write_file(&game_root.join("version.ini"), b"20250101");
|
||||
let ctx = test_ctx(
|
||||
temp.path().to_path_buf(),
|
||||
HashSet::from(["game".to_string()]),
|
||||
GameCatalog::from_ids(["game".to_string()]),
|
||||
);
|
||||
let gate = RescanGate::default();
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
@@ -551,7 +552,7 @@ mod tests {
|
||||
write_file(&temp.path().join("game").join("version.ini"), b"20250101");
|
||||
let ctx = test_ctx(
|
||||
temp.path().to_path_buf(),
|
||||
HashSet::from(["game".to_string()]),
|
||||
GameCatalog::from_ids(["game".to_string()]),
|
||||
);
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
@@ -575,7 +576,7 @@ mod tests {
|
||||
);
|
||||
let ctx = test_ctx(
|
||||
temp.path().to_path_buf(),
|
||||
HashSet::from(["game".to_string()]),
|
||||
GameCatalog::from_ids(["game".to_string()]),
|
||||
);
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{
|
||||
context::PeerCtx,
|
||||
error::PeerError,
|
||||
events,
|
||||
local_games::{get_game_file_descriptions, is_local_dir_name, local_download_available},
|
||||
local_games::{get_game_file_descriptions, is_local_dir_name, local_download_matches_catalog},
|
||||
peer::{send_game_file_chunk, send_game_file_data},
|
||||
services::handshake::{HandshakeCtx, accept_inbound_hello, spawn_library_resync},
|
||||
};
|
||||
@@ -162,7 +162,7 @@ async fn handle_library_delta(ctx: &PeerCtx, peer_id: String, delta: LibraryDelt
|
||||
};
|
||||
|
||||
if applied {
|
||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.tx_notify_ui).await;
|
||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await;
|
||||
} else {
|
||||
let addr = {
|
||||
let db = ctx.peer_game_db.read().await;
|
||||
@@ -209,7 +209,7 @@ async fn get_game_response(ctx: &PeerCtx, id: String) -> Response {
|
||||
async fn can_serve_game(ctx: &PeerCtx, game_dir: &std::path::Path, game_id: &str) -> bool {
|
||||
let active_operations = ctx.active_operations.read().await;
|
||||
let catalog = ctx.catalog.read().await;
|
||||
local_download_available(game_dir, game_id, &active_operations, &catalog).await
|
||||
local_download_matches_catalog(game_dir, game_id, &active_operations, &catalog).await
|
||||
}
|
||||
|
||||
async fn can_dispatch_file_transfer(
|
||||
@@ -232,6 +232,67 @@ fn path_points_inside_local(game_id: &str, relative_path: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static NEXT_TRANSFER_ID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
struct TransferGuard {
|
||||
game_id: String,
|
||||
id: u64,
|
||||
active_outbound_transfers: crate::context::OutboundTransfers,
|
||||
tx_notify_ui: tokio::sync::mpsc::UnboundedSender<crate::PeerEvent>,
|
||||
}
|
||||
|
||||
impl TransferGuard {
|
||||
async fn new(
|
||||
game_id: String,
|
||||
active_outbound_transfers: crate::context::OutboundTransfers,
|
||||
tx_notify_ui: tokio::sync::mpsc::UnboundedSender<crate::PeerEvent>,
|
||||
shutdown: &tokio_util::sync::CancellationToken,
|
||||
) -> (Self, tokio_util::sync::CancellationToken) {
|
||||
let id = NEXT_TRANSFER_ID.fetch_add(1, Ordering::SeqCst);
|
||||
let token = shutdown.child_token();
|
||||
{
|
||||
let mut active = active_outbound_transfers.write().await;
|
||||
active
|
||||
.entry(game_id.clone())
|
||||
.or_default()
|
||||
.push((id, token.clone()));
|
||||
}
|
||||
let _ = tx_notify_ui.send(crate::PeerEvent::OutboundTransferCountChanged);
|
||||
(
|
||||
Self {
|
||||
game_id,
|
||||
id,
|
||||
active_outbound_transfers,
|
||||
tx_notify_ui,
|
||||
},
|
||||
token,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TransferGuard {
|
||||
fn drop(&mut self) {
|
||||
let game_id = self.game_id.clone();
|
||||
let id = self.id;
|
||||
let active_outbound_transfers = self.active_outbound_transfers.clone();
|
||||
let tx_notify_ui = self.tx_notify_ui.clone();
|
||||
tokio::spawn(async move {
|
||||
{
|
||||
let mut active = active_outbound_transfers.write().await;
|
||||
if let Some(tokens) = active.get_mut(&game_id) {
|
||||
tokens.retain(|(tid, _)| *tid != id);
|
||||
if tokens.is_empty() {
|
||||
active.remove(&game_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = tx_notify_ui.send(crate::PeerEvent::OutboundTransferCountChanged);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_file_data_request(
|
||||
ctx: &PeerCtx,
|
||||
desc: GameFileDescription,
|
||||
@@ -242,6 +303,14 @@ async fn handle_file_data_request(
|
||||
desc.relative_path
|
||||
);
|
||||
|
||||
let (guard, cancel_token) = TransferGuard::new(
|
||||
desc.game_id.clone(),
|
||||
ctx.active_outbound_transfers.clone(),
|
||||
ctx.tx_notify_ui.clone(),
|
||||
&ctx.shutdown,
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut tx = framed_tx.into_inner();
|
||||
let game_dir = ctx.game_dir.read().await.clone();
|
||||
if !can_dispatch_file_transfer(ctx, &game_dir, &desc.game_id, &desc.relative_path).await {
|
||||
@@ -249,11 +318,13 @@ async fn handle_file_data_request(
|
||||
"Declining GetGameFileData for {} because the game is not currently transferable",
|
||||
desc.relative_path
|
||||
);
|
||||
drop(guard);
|
||||
let _ = tx.close().await;
|
||||
return FramedWrite::new(tx, LengthDelimitedCodec::new());
|
||||
}
|
||||
|
||||
send_game_file_data(&desc, &mut tx, &game_dir).await;
|
||||
send_game_file_data(&desc, &mut tx, &game_dir, cancel_token).await;
|
||||
drop(guard);
|
||||
FramedWrite::new(tx, LengthDelimitedCodec::new())
|
||||
}
|
||||
|
||||
@@ -269,17 +340,36 @@ async fn handle_file_chunk_request(
|
||||
"Received GetGameFileChunk request for {relative_path} (offset {offset}, length {length})"
|
||||
);
|
||||
|
||||
let (guard, cancel_token) = TransferGuard::new(
|
||||
game_id.clone(),
|
||||
ctx.active_outbound_transfers.clone(),
|
||||
ctx.tx_notify_ui.clone(),
|
||||
&ctx.shutdown,
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut tx = framed_tx.into_inner();
|
||||
let game_dir = ctx.game_dir.read().await.clone();
|
||||
if !can_dispatch_file_transfer(ctx, &game_dir, &game_id, &relative_path).await {
|
||||
log::info!(
|
||||
"Declining GetGameFileChunk for {relative_path} because the game is not currently transferable"
|
||||
);
|
||||
drop(guard);
|
||||
let _ = tx.close().await;
|
||||
return FramedWrite::new(tx, LengthDelimitedCodec::new());
|
||||
}
|
||||
|
||||
send_game_file_chunk(&game_id, &relative_path, offset, length, &mut tx, &game_dir).await;
|
||||
send_game_file_chunk(
|
||||
&game_id,
|
||||
&relative_path,
|
||||
offset,
|
||||
length,
|
||||
&mut tx,
|
||||
&game_dir,
|
||||
cancel_token,
|
||||
)
|
||||
.await;
|
||||
drop(guard);
|
||||
FramedWrite::new(tx, LengthDelimitedCodec::new())
|
||||
}
|
||||
|
||||
@@ -289,17 +379,17 @@ async fn handle_goodbye(ctx: &PeerCtx, _remote_addr: Option<SocketAddr>, peer_id
|
||||
let Some(peer) = removed else { return };
|
||||
|
||||
events::emit_peer_lost(&ctx.peer_game_db, &ctx.tx_notify_ui, peer.addr).await;
|
||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.tx_notify_ui).await;
|
||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use lanspread_db::db::GameCatalog;
|
||||
use tokio::sync::{RwLock, mpsc};
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
|
||||
@@ -327,7 +417,7 @@ mod tests {
|
||||
std::fs::write(path, bytes).expect("file should be written");
|
||||
}
|
||||
|
||||
fn test_ctx(game_dir: PathBuf, catalog: HashSet<String>) -> PeerCtx {
|
||||
fn test_ctx(game_dir: PathBuf, catalog: GameCatalog) -> PeerCtx {
|
||||
let (tx_notify_ui, _rx) = mpsc::unbounded_channel();
|
||||
Ctx::new(
|
||||
Arc::new(RwLock::new(PeerGameDB::new())),
|
||||
@@ -338,6 +428,7 @@ mod tests {
|
||||
CancellationToken::new(),
|
||||
TaskTracker::new(),
|
||||
Arc::new(RwLock::new(catalog)),
|
||||
Arc::new(RwLock::new(std::collections::HashMap::new())),
|
||||
)
|
||||
.to_peer_ctx(tx_notify_ui)
|
||||
}
|
||||
@@ -360,17 +451,19 @@ mod tests {
|
||||
b"20250101",
|
||||
);
|
||||
write_file(&temp.path().join("active").join("version.ini"), b"20250101");
|
||||
write_file(
|
||||
&temp.path().join("wrong-version").join("version.ini"),
|
||||
b"20260101",
|
||||
);
|
||||
std::fs::create_dir_all(temp.path().join("missing-sentinel"))
|
||||
.expect("missing sentinel root should be created");
|
||||
|
||||
let ctx = test_ctx(
|
||||
temp.path().to_path_buf(),
|
||||
HashSet::from([
|
||||
"ready".to_string(),
|
||||
"active".to_string(),
|
||||
"missing-sentinel".to_string(),
|
||||
]),
|
||||
);
|
||||
let mut catalog = GameCatalog::empty();
|
||||
catalog.insert("ready".to_string(), Some("20250101".to_string()));
|
||||
catalog.insert("active".to_string(), Some("20250101".to_string()));
|
||||
catalog.insert("missing-sentinel".to_string(), Some("20250101".to_string()));
|
||||
catalog.insert("wrong-version".to_string(), Some("20250101".to_string()));
|
||||
let ctx = test_ctx(temp.path().to_path_buf(), catalog);
|
||||
ctx.active_operations
|
||||
.write()
|
||||
.await
|
||||
@@ -388,6 +481,10 @@ mod tests {
|
||||
get_game_response(&ctx, "active".to_string()).await,
|
||||
Response::GameNotFound(id) if id == "active"
|
||||
));
|
||||
assert!(matches!(
|
||||
get_game_response(&ctx, "wrong-version".to_string()).await,
|
||||
Response::GameNotFound(id) if id == "wrong-version"
|
||||
));
|
||||
assert!(matches!(
|
||||
get_game_response(&ctx, "missing-sentinel".to_string()).await,
|
||||
Response::GameNotFound(id) if id == "missing-sentinel"
|
||||
@@ -403,17 +500,19 @@ mod tests {
|
||||
b"20250101",
|
||||
);
|
||||
write_file(&temp.path().join("active").join("version.ini"), b"20250101");
|
||||
write_file(
|
||||
&temp.path().join("wrong-version").join("version.ini"),
|
||||
b"20260101",
|
||||
);
|
||||
std::fs::create_dir_all(temp.path().join("missing-sentinel"))
|
||||
.expect("missing sentinel root should be created");
|
||||
|
||||
let ctx = test_ctx(
|
||||
temp.path().to_path_buf(),
|
||||
HashSet::from([
|
||||
"ready".to_string(),
|
||||
"active".to_string(),
|
||||
"missing-sentinel".to_string(),
|
||||
]),
|
||||
);
|
||||
let mut catalog = GameCatalog::empty();
|
||||
catalog.insert("ready".to_string(), Some("20250101".to_string()));
|
||||
catalog.insert("active".to_string(), Some("20250101".to_string()));
|
||||
catalog.insert("missing-sentinel".to_string(), Some("20250101".to_string()));
|
||||
catalog.insert("wrong-version".to_string(), Some("20250101".to_string()));
|
||||
let ctx = test_ctx(temp.path().to_path_buf(), catalog);
|
||||
ctx.active_operations
|
||||
.write()
|
||||
.await
|
||||
@@ -432,6 +531,15 @@ mod tests {
|
||||
assert!(
|
||||
!can_dispatch_file_transfer(&ctx, temp.path(), "active", "active/version.ini").await
|
||||
);
|
||||
assert!(
|
||||
!can_dispatch_file_transfer(
|
||||
&ctx,
|
||||
temp.path(),
|
||||
"wrong-version",
|
||||
"wrong-version/version.ini",
|
||||
)
|
||||
.await
|
||||
);
|
||||
assert!(
|
||||
!can_dispatch_file_transfer(
|
||||
&ctx,
|
||||
|
||||
Reference in New Issue
Block a user