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:
@@ -39,12 +39,12 @@ mod test_support;
|
||||
// Public re-exports
|
||||
// =============================================================================
|
||||
|
||||
use std::{collections::HashSet, net::SocketAddr, path::PathBuf, sync::Arc};
|
||||
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
|
||||
|
||||
pub use config::{CHUNK_SIZE, MAX_RETRY_COUNT};
|
||||
pub use error::PeerError;
|
||||
pub use install::{UnpackFuture, Unpacker};
|
||||
use lanspread_db::db::{Game, GameFileDescription};
|
||||
use lanspread_db::db::{Game, GameCatalog, GameFileDescription};
|
||||
pub use migration::{MigrationReport, migrate_legacy_state};
|
||||
pub use peer_db::{
|
||||
MajorityValidationResult,
|
||||
@@ -153,6 +153,8 @@ pub enum PeerEvent {
|
||||
PeerCountUpdated(usize),
|
||||
/// The local library contents changed after a scan.
|
||||
LocalLibraryChanged { games: Vec<Game> },
|
||||
/// The number of active outbound transfers changed.
|
||||
OutboundTransferCountChanged,
|
||||
/// The set of in-progress local operations changed.
|
||||
ActiveOperationsChanged {
|
||||
active_operations: Vec<ActiveOperation>,
|
||||
@@ -262,6 +264,7 @@ pub enum PeerCommand {
|
||||
pub struct PeerStartOptions {
|
||||
/// Directory used for peer identity and other state.
|
||||
pub state_dir: Option<PathBuf>,
|
||||
pub active_outbound_transfers: Option<crate::context::OutboundTransfers>,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -286,7 +289,7 @@ pub fn start_peer(
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
catalog: Arc<RwLock<HashSet<String>>>,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
) -> eyre::Result<PeerRuntimeHandle> {
|
||||
start_peer_with_options(
|
||||
game_dir,
|
||||
@@ -305,12 +308,17 @@ pub fn start_peer_with_options(
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
catalog: Arc<RwLock<HashSet<String>>>,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
options: PeerStartOptions,
|
||||
) -> eyre::Result<PeerRuntimeHandle> {
|
||||
let PeerStartOptions { state_dir } = options;
|
||||
let PeerStartOptions {
|
||||
state_dir,
|
||||
active_outbound_transfers,
|
||||
} = options;
|
||||
let state_dir = resolve_state_dir(state_dir.as_deref());
|
||||
let game_dir = game_dir.into();
|
||||
let active_outbound_transfers = active_outbound_transfers
|
||||
.unwrap_or_else(|| Arc::new(RwLock::new(std::collections::HashMap::new())));
|
||||
log::info!(
|
||||
"Starting peer system with game directory: {}",
|
||||
game_dir.display()
|
||||
@@ -329,6 +337,7 @@ pub fn start_peer_with_options(
|
||||
state_dir,
|
||||
unpacker,
|
||||
catalog,
|
||||
active_outbound_transfers,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -344,7 +353,8 @@ async fn run_peer(
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
shutdown: CancellationToken,
|
||||
task_tracker: TaskTracker,
|
||||
catalog: Arc<RwLock<HashSet<String>>>,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
active_outbound_transfers: crate::context::OutboundTransfers,
|
||||
) -> eyre::Result<()> {
|
||||
let ctx = Ctx::new(
|
||||
peer_game_db,
|
||||
@@ -355,6 +365,7 @@ async fn run_peer(
|
||||
shutdown,
|
||||
task_tracker,
|
||||
catalog,
|
||||
active_outbound_transfers,
|
||||
);
|
||||
if let Err(err) = load_local_library(&ctx, &tx_notify_ui).await {
|
||||
log::error!("Failed to load initial local game database: {err}");
|
||||
|
||||
Reference in New Issue
Block a user