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:
@@ -9,7 +9,7 @@ use std::{
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use lanspread_db::db::{Game, GameDB, GameFileDescription};
|
||||
use lanspread_db::db::{Game, GameCatalog, GameDB, GameFileDescription};
|
||||
use lanspread_proto::{Availability, GameSummary};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::{io::AsyncWriteExt, sync::Mutex};
|
||||
@@ -51,7 +51,7 @@ pub async fn local_download_available(
|
||||
game_dir: &Path,
|
||||
game_id: &str,
|
||||
active_operations: &HashMap<String, OperationKind>,
|
||||
catalog: &HashSet<String>,
|
||||
catalog: &GameCatalog,
|
||||
) -> bool {
|
||||
if !catalog.contains(game_id) {
|
||||
log::debug!("Not serving game {game_id} locally because it is not in the catalog");
|
||||
@@ -67,6 +67,40 @@ pub async fn local_download_available(
|
||||
version_ini_is_regular_file(game_path.as_path()).await
|
||||
}
|
||||
|
||||
/// Checks if a local game may be served to peers under the authoritative catalog version.
|
||||
pub async fn local_download_matches_catalog(
|
||||
game_dir: &Path,
|
||||
game_id: &str,
|
||||
active_operations: &HashMap<String, OperationKind>,
|
||||
catalog: &GameCatalog,
|
||||
) -> bool {
|
||||
if !local_download_available(game_dir, game_id, active_operations, catalog).await {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(expected_version) = catalog.expected_version(game_id) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let game_path = game_dir.join(game_id);
|
||||
match lanspread_db::db::read_version_from_ini(&game_path) {
|
||||
Ok(Some(local_version)) if local_version == expected_version => true,
|
||||
Ok(Some(local_version)) => {
|
||||
log::debug!(
|
||||
"Not serving game {game_id}: local version.ini {local_version} does not match catalog {expected_version}"
|
||||
);
|
||||
false
|
||||
}
|
||||
Ok(None) => false,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Not serving game {game_id}: failed to read local version.ini for catalog comparison: {err}"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Local library index and scanning
|
||||
// =============================================================================
|
||||
@@ -468,7 +502,7 @@ struct IndexUpdate {
|
||||
async fn update_index_for_game(
|
||||
game_root: &Path,
|
||||
game_id: &str,
|
||||
catalog: &HashSet<String>,
|
||||
catalog: &GameCatalog,
|
||||
index: &mut LibraryIndex,
|
||||
) -> eyre::Result<IndexUpdate> {
|
||||
if !catalog.contains(game_id) {
|
||||
@@ -557,7 +591,7 @@ fn scan_from_index(index: &LibraryIndex) -> LocalLibraryScan {
|
||||
pub async fn scan_local_library(
|
||||
game_dir: impl AsRef<Path>,
|
||||
state_dir: impl AsRef<Path>,
|
||||
catalog: &HashSet<String>,
|
||||
catalog: &GameCatalog,
|
||||
) -> eyre::Result<LocalLibraryScan> {
|
||||
let game_path = game_dir.as_ref();
|
||||
let state_path = state_dir.as_ref();
|
||||
@@ -645,7 +679,7 @@ pub async fn scan_local_library(
|
||||
pub async fn rescan_local_game(
|
||||
game_dir: impl AsRef<Path>,
|
||||
state_dir: impl AsRef<Path>,
|
||||
catalog: &HashSet<String>,
|
||||
catalog: &GameCatalog,
|
||||
game_id: &str,
|
||||
) -> eyre::Result<LocalLibraryScan> {
|
||||
let game_path = game_dir.as_ref();
|
||||
@@ -682,10 +716,7 @@ pub async fn get_game_file_descriptions(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::Path,
|
||||
};
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
use lanspread_proto::Availability;
|
||||
|
||||
@@ -776,7 +807,7 @@ mod tests {
|
||||
async fn scan_uses_version_ini_and_local_dir_as_independent_state() {
|
||||
let temp = TempDir::new("lanspread-local-games");
|
||||
let state = TempDir::new("lanspread-local-games-state");
|
||||
let catalog = HashSet::from([
|
||||
let catalog = GameCatalog::from_ids([
|
||||
"ready".to_string(),
|
||||
"local-only".to_string(),
|
||||
"eti-only".to_string(),
|
||||
@@ -830,7 +861,7 @@ mod tests {
|
||||
async fn rescan_promotes_installed_only_game_to_ready_when_sentinel_appears() {
|
||||
let temp = TempDir::new("lanspread-local-games");
|
||||
let state = TempDir::new("lanspread-local-games-state");
|
||||
let catalog = HashSet::from(["game".to_string()]);
|
||||
let catalog = GameCatalog::from_ids(["game".to_string()]);
|
||||
std::fs::create_dir_all(temp.path().join("game").join("local"))
|
||||
.expect("local install dir should be created");
|
||||
|
||||
@@ -864,7 +895,7 @@ mod tests {
|
||||
async fn concurrent_rescans_preserve_both_index_updates() {
|
||||
let temp = TempDir::new("lanspread-local-games-concurrent");
|
||||
let state = TempDir::new("lanspread-local-games-state");
|
||||
let catalog = HashSet::from(["game-a".to_string(), "game-b".to_string()]);
|
||||
let catalog = GameCatalog::from_ids(["game-a".to_string(), "game-b".to_string()]);
|
||||
write_file(&temp.path().join("game-a").join("version.ini"), b"20250101");
|
||||
write_file(&temp.path().join("game-b").join("version.ini"), b"20250101");
|
||||
|
||||
@@ -909,7 +940,7 @@ mod tests {
|
||||
let game_root = temp.path().join("game");
|
||||
write_file(&game_root.join("version.ini"), b"20250101");
|
||||
|
||||
let catalog = HashSet::from(["game".to_string()]);
|
||||
let catalog = GameCatalog::from_ids(["game".to_string()]);
|
||||
let no_operations = HashMap::new();
|
||||
assert!(local_download_available(temp.path(), "game", &no_operations, &catalog).await);
|
||||
|
||||
@@ -917,8 +948,29 @@ mod tests {
|
||||
assert!(!local_download_available(temp.path(), "game", &active_operations, &catalog).await);
|
||||
|
||||
assert!(
|
||||
!local_download_available(temp.path(), "game", &no_operations, &HashSet::new()).await
|
||||
!local_download_available(temp.path(), "game", &no_operations, &GameCatalog::empty())
|
||||
.await
|
||||
);
|
||||
assert!(!local_download_available(temp.path(), "missing", &no_operations, &catalog).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_download_matches_catalog_requires_expected_version() {
|
||||
let temp = TempDir::new("lanspread-local-games");
|
||||
let game_root = temp.path().join("game");
|
||||
write_file(&game_root.join("version.ini"), b"20260101");
|
||||
|
||||
let mut catalog = GameCatalog::empty();
|
||||
catalog.insert("game".to_string(), Some("20250101".to_string()));
|
||||
let no_operations = HashMap::new();
|
||||
|
||||
assert!(
|
||||
!local_download_matches_catalog(temp.path(), "game", &no_operations, &catalog).await
|
||||
);
|
||||
|
||||
catalog.insert("game".to_string(), Some("20260101".to_string()));
|
||||
assert!(
|
||||
local_download_matches_catalog(temp.path(), "game", &no_operations, &catalog).await
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user