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:
@@ -8,7 +8,7 @@ use std::{
|
||||
|
||||
use eyre::bail;
|
||||
use lanspread_compat::eti::get_games;
|
||||
use lanspread_db::db::{Availability, Game, GameDB, GameFileDescription};
|
||||
use lanspread_db::db::{Availability, Game, GameCatalog, GameDB, GameFileDescription};
|
||||
use lanspread_peer::{
|
||||
ActiveOperation,
|
||||
ActiveOperationKind,
|
||||
@@ -31,6 +31,10 @@ use tokio::sync::{
|
||||
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
|
||||
type OutboundTransfers = Arc<
|
||||
RwLock<std::collections::HashMap<String, Vec<(u64, tokio_util::sync::CancellationToken)>>>,
|
||||
>;
|
||||
|
||||
/// Tauri-managed runtime state shared by commands and setup tasks.
|
||||
#[derive(Default)]
|
||||
struct LanSpreadState {
|
||||
@@ -40,9 +44,10 @@ struct LanSpreadState {
|
||||
active_operations: Arc<RwLock<HashMap<String, UiOperationKind>>>,
|
||||
games_folder: Arc<RwLock<String>>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
catalog: Arc<RwLock<HashSet<String>>>,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
unpack_logs: Arc<RwLock<Vec<UnpackLogEntry>>>,
|
||||
state_dir: OnceLock<PathBuf>,
|
||||
active_outbound_transfers: OutboundTransfers,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -79,6 +84,7 @@ struct LauncherGame {
|
||||
#[serde(flatten)]
|
||||
game: Game,
|
||||
can_host_server: bool,
|
||||
active_outbound_transfers: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
|
||||
@@ -829,6 +835,24 @@ fn apply_peer_local_games(game_db: &mut GameDB, local_games: &[Game]) {
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_peer_remote_games(game_db: &mut GameDB, peer_games: Vec<Game>) {
|
||||
// Peer events update availability, but catalog metadata stays anchored to game.db.
|
||||
for game in game_db.games.values_mut() {
|
||||
game.peer_count = 0;
|
||||
}
|
||||
|
||||
for peer_game in peer_games {
|
||||
if let Some(existing) = game_db.get_mut_game_by_id(&peer_game.id) {
|
||||
existing.peer_count = peer_game.peer_count;
|
||||
} else {
|
||||
log::debug!(
|
||||
"Peer advertised unknown game {id}; ignoring because game.db is ground truth",
|
||||
id = peer_game.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_all_local_game_states(game_db: &mut GameDB) {
|
||||
for game in game_db.games.values_mut() {
|
||||
clear_local_game_state(game);
|
||||
@@ -847,17 +871,24 @@ async fn emit_games_list(app_handle: &AppHandle) {
|
||||
return;
|
||||
}
|
||||
|
||||
let active_transfers = state.active_outbound_transfers.read().await;
|
||||
|
||||
let games_to_emit = game_db
|
||||
.all_games()
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.map(|game| LauncherGame {
|
||||
can_host_server: game_can_host_server(&games_folder, &game),
|
||||
game,
|
||||
.map(|game| {
|
||||
let active_outbound_transfers = active_transfers.get(&game.id).map_or(0, Vec::len);
|
||||
LauncherGame {
|
||||
can_host_server: game_can_host_server(&games_folder, &game),
|
||||
active_outbound_transfers,
|
||||
game,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<LauncherGame>>();
|
||||
|
||||
drop(game_db);
|
||||
drop(active_transfers);
|
||||
|
||||
let active_operations = {
|
||||
let active_operations = state.active_operations.read().await;
|
||||
@@ -996,36 +1027,7 @@ async fn update_game_db(games: Vec<Game>, app: AppHandle) {
|
||||
|
||||
{
|
||||
let mut game_db = state.games.write().await;
|
||||
|
||||
// Reset peer counts up front. Presence/metadata stay anchored to the baked game.db.
|
||||
for game in game_db.games.values_mut() {
|
||||
game.peer_count = 0;
|
||||
}
|
||||
|
||||
for peer_game in games {
|
||||
if let Some(existing) = game_db.get_mut_game_by_id(&peer_game.id) {
|
||||
existing.peer_count = peer_game.peer_count;
|
||||
|
||||
if let Some(peer_version) = &peer_game.eti_game_version {
|
||||
match &existing.eti_game_version {
|
||||
Some(current_version) if current_version >= peer_version => {}
|
||||
_ => {
|
||||
existing.eti_game_version = Some(peer_version.clone());
|
||||
log::debug!(
|
||||
"Updated eti_game_version for {} to {} based on peer data",
|
||||
peer_game.id,
|
||||
peer_version
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::debug!(
|
||||
"Peer advertised unknown game {id}; ignoring because game.db is ground truth",
|
||||
id = peer_game.id
|
||||
);
|
||||
}
|
||||
}
|
||||
apply_peer_remote_games(&mut game_db, games);
|
||||
}
|
||||
|
||||
emit_games_list(&app).await;
|
||||
@@ -1399,7 +1401,7 @@ async fn ensure_bundled_game_db_loaded(app_handle: &AppHandle) {
|
||||
|
||||
if needs_load {
|
||||
let game_db = load_bundled_game_db(app_handle).await;
|
||||
let catalog = game_db.games.keys().cloned().collect::<HashSet<_>>();
|
||||
let catalog = GameCatalog::from_game_db(&game_db);
|
||||
*state.games.write().await = game_db;
|
||||
*state.catalog.write().await = catalog;
|
||||
}
|
||||
@@ -1432,6 +1434,7 @@ async fn ensure_peer_started(app_handle: &AppHandle, games_folder: &Path) {
|
||||
state.catalog.clone(),
|
||||
PeerStartOptions {
|
||||
state_dir: Some(state_dir),
|
||||
active_outbound_transfers: Some(state.active_outbound_transfers.clone()),
|
||||
},
|
||||
) {
|
||||
Ok(handle) => {
|
||||
@@ -1495,6 +1498,10 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
||||
}
|
||||
emit_games_list(app_handle).await;
|
||||
}
|
||||
PeerEvent::OutboundTransferCountChanged => {
|
||||
log::info!("PeerEvent::OutboundTransferCountChanged received");
|
||||
emit_games_list(app_handle).await;
|
||||
}
|
||||
PeerEvent::GotGameFiles {
|
||||
id,
|
||||
file_descriptions,
|
||||
@@ -1747,6 +1754,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn eti_game_fixture(game_id: &str, game_version: &str) -> lanspread_compat::eti::EtiGame {
|
||||
lanspread_compat::eti::EtiGame {
|
||||
game_id: game_id.to_string(),
|
||||
game_title: "Catalog Game".to_string(),
|
||||
game_key: "catalog-game".to_string(),
|
||||
game_release: "2000".to_string(),
|
||||
game_publisher: "publisher".to_string(),
|
||||
game_size: 1.0,
|
||||
game_readme_de: "description".to_string(),
|
||||
game_readme_en: "description".to_string(),
|
||||
game_readme_fr: "description".to_string(),
|
||||
game_maxplayers: 4,
|
||||
game_master_req: 0,
|
||||
genre_de: "genre".to_string(),
|
||||
game_version: game_version.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eti_game_conversion_uses_catalog_version_as_authoritative_eti_version() {
|
||||
let game = Game::from(eti_game_fixture("alpha", "20200721"));
|
||||
|
||||
assert_eq!(game.version, "20200721");
|
||||
assert_eq!(game.eti_game_version.as_deref(), Some("20200721"));
|
||||
assert_eq!(game.local_version, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_log_cleanup_preserves_crlf_and_collapses_redrawn_lines() {
|
||||
let input = "Extracting foo 10%\rExtracting foo 80%\rExtracting foo OK\r\nAll done\r\n";
|
||||
@@ -2048,6 +2082,42 @@ mod tests {
|
||||
|
||||
assert!(game_db.get_game_by_id("unknown").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_remote_snapshot_updates_counts_without_overwriting_catalog_version() {
|
||||
let mut alpha = game_fixture("alpha", "Catalog Alpha");
|
||||
alpha.size = 999;
|
||||
alpha.eti_game_version = Some("20200721".to_string());
|
||||
|
||||
let mut beta = game_fixture("beta", "Catalog Beta");
|
||||
beta.peer_count = 2;
|
||||
beta.eti_game_version = Some("20200101".to_string());
|
||||
|
||||
let mut game_db = GameDB::from(vec![alpha, beta]);
|
||||
|
||||
let mut peer_alpha = game_fixture("alpha", "Peer Alpha");
|
||||
peer_alpha.size = 42;
|
||||
peer_alpha.peer_count = 3;
|
||||
peer_alpha.eti_game_version = Some("20990101".to_string());
|
||||
|
||||
let mut unknown = game_fixture("unknown", "Unknown");
|
||||
unknown.peer_count = 1;
|
||||
unknown.eti_game_version = Some("20990101".to_string());
|
||||
|
||||
apply_peer_remote_games(&mut game_db, vec![peer_alpha, unknown]);
|
||||
|
||||
let alpha = game_db.get_game_by_id("alpha").expect("alpha remains");
|
||||
assert_eq!(alpha.name, "Catalog Alpha");
|
||||
assert_eq!(alpha.size, 999);
|
||||
assert_eq!(alpha.peer_count, 3);
|
||||
assert_eq!(alpha.eti_game_version.as_deref(), Some("20200721"));
|
||||
|
||||
let beta = game_db.get_game_by_id("beta").expect("beta remains");
|
||||
assert_eq!(beta.peer_count, 0);
|
||||
assert_eq!(beta.eti_game_version.as_deref(), Some("20200101"));
|
||||
|
||||
assert!(game_db.get_game_by_id("unknown").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::missing_panics_doc)]
|
||||
|
||||
Reference in New Issue
Block a user