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:
@@ -78,7 +78,7 @@ pub struct Game {
|
||||
/// Backend-reported availability state for this game's local or peer summary.
|
||||
#[serde(default)]
|
||||
pub availability: Availability,
|
||||
/// ETI game version from version.ini (YYYYMMDD format) (server)
|
||||
/// Authoritative ETI game version from the bundled game.db (YYYYMMDD format).
|
||||
pub eti_game_version: Option<String>,
|
||||
/// Local game version from version.ini (YYYYMMDD format)
|
||||
pub local_version: Option<String>,
|
||||
@@ -198,6 +198,60 @@ impl Default for GameDB {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct GameCatalog {
|
||||
expected_versions: HashMap<String, Option<String>>,
|
||||
}
|
||||
|
||||
impl GameCatalog {
|
||||
#[must_use]
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
expected_versions: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_game_db(game_db: &GameDB) -> Self {
|
||||
Self {
|
||||
expected_versions: game_db
|
||||
.games
|
||||
.values()
|
||||
.map(|game| (game.id.clone(), game.eti_game_version.clone()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_ids(ids: impl IntoIterator<Item = String>) -> Self {
|
||||
Self {
|
||||
expected_versions: ids.into_iter().map(|id| (id, None)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, id: String, expected_version: Option<String>) {
|
||||
self.expected_versions.insert(id, expected_version);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn contains<S>(&self, id: S) -> bool
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
self.expected_versions.contains_key(id.as_ref())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn expected_version<S>(&self, id: S) -> Option<&str>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
self.expected_versions
|
||||
.get(id.as_ref())
|
||||
.and_then(Option::as_deref)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct GameFileDescription {
|
||||
pub game_id: String,
|
||||
|
||||
Reference in New Issue
Block a user