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:
@@ -12,7 +12,7 @@ use std::{
|
||||
|
||||
use eyre::Context;
|
||||
use lanspread_compat::eti::get_games;
|
||||
use lanspread_db::db::{Game, GameFileDescription};
|
||||
use lanspread_db::db::{Game, GameCatalog, GameFileDescription};
|
||||
use lanspread_peer::{
|
||||
ActiveOperation,
|
||||
ActiveOperationKind,
|
||||
@@ -30,6 +30,7 @@ use lanspread_peer::{
|
||||
use lanspread_peer_cli::{
|
||||
CliCommand,
|
||||
CommandEnvelope,
|
||||
DEFAULT_FIXTURE_VERSION,
|
||||
ExternalUnrarUnpacker,
|
||||
FixtureSeed,
|
||||
FixtureUnpacker,
|
||||
@@ -114,7 +115,7 @@ struct DownloadMeasurement {
|
||||
struct SharedState {
|
||||
state: RwLock<CliState>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
catalog: Arc<RwLock<HashSet<String>>>,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
notify: Notify,
|
||||
games_dir: PathBuf,
|
||||
state_dir: PathBuf,
|
||||
@@ -146,6 +147,7 @@ async fn main() -> eyre::Result<()> {
|
||||
catalog.clone(),
|
||||
PeerStartOptions {
|
||||
state_dir: Some(args.state_dir.clone()),
|
||||
active_outbound_transfers: None,
|
||||
},
|
||||
)?;
|
||||
let sender = handle.sender();
|
||||
@@ -303,15 +305,8 @@ async fn list_peers(shared: &SharedState) -> eyre::Result<Value> {
|
||||
|
||||
async fn list_games(shared: &SharedState) -> eyre::Result<Value> {
|
||||
let state = shared.state.read().await;
|
||||
let catalog = shared.catalog.read().await.clone();
|
||||
let remote = shared
|
||||
.peer_game_db
|
||||
.read()
|
||||
.await
|
||||
.get_all_games()
|
||||
.into_iter()
|
||||
.filter(|game| catalog.contains(&game.id))
|
||||
.collect::<Vec<_>>();
|
||||
let catalog = shared.catalog.read().await;
|
||||
let remote = shared.peer_game_db.read().await.get_catalog_games(&catalog);
|
||||
Ok(json!({
|
||||
"local": state.local_games.clone(),
|
||||
"remote": remote,
|
||||
@@ -434,6 +429,7 @@ async fn event_loop(
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'static str, Value) {
|
||||
match event {
|
||||
PeerEvent::LocalPeerReady { peer_id, addr } => {
|
||||
@@ -458,6 +454,7 @@ async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'s
|
||||
state.local_games.clone_from(&games);
|
||||
("local-library-changed", json!({ "games": games }))
|
||||
}
|
||||
PeerEvent::OutboundTransferCountChanged => ("outbound-transfer-count-changed", json!({})),
|
||||
PeerEvent::ActiveOperationsChanged { active_operations } => {
|
||||
let mut state = shared.state.write().await;
|
||||
state.active_operations.clone_from(&active_operations);
|
||||
@@ -668,18 +665,27 @@ fn seed_fixtures(game_dir: &Path, fixtures: &[String]) -> eyre::Result<Vec<Fixtu
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn load_catalog(catalog_db: Option<&Path>, fixtures: &[FixtureSeed]) -> HashSet<String> {
|
||||
let mut catalog = HashSet::new();
|
||||
async fn load_catalog(catalog_db: Option<&Path>, fixtures: &[FixtureSeed]) -> GameCatalog {
|
||||
let mut catalog = GameCatalog::empty();
|
||||
if let Some(path) = catalog_db
|
||||
&& path.exists()
|
||||
{
|
||||
match get_games(path).await {
|
||||
Ok(games) => catalog.extend(games.into_iter().map(|game| game.game_id)),
|
||||
Ok(games) => {
|
||||
for game in games {
|
||||
catalog.insert(game.game_id, Some(game.game_version));
|
||||
}
|
||||
}
|
||||
Err(err) => eprintln!("failed to load catalog db {}: {err}", path.display()),
|
||||
}
|
||||
}
|
||||
|
||||
catalog.extend(fixtures.iter().map(|seed| seed.game_id.clone()));
|
||||
for seed in fixtures {
|
||||
catalog.insert(
|
||||
seed.game_id.clone(),
|
||||
Some(DEFAULT_FIXTURE_VERSION.to_string()),
|
||||
);
|
||||
}
|
||||
catalog
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user