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:
@@ -14,12 +14,14 @@ use tokio::{
|
||||
|
||||
use crate::{config::FILE_TRANSFER_BUFFER_SIZE, path_validation::validate_game_file_path};
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn stream_file_bytes(
|
||||
tx: &mut SendStream,
|
||||
base_dir: &Path,
|
||||
relative_path: &str,
|
||||
offset: u64,
|
||||
length: Option<u64>,
|
||||
cancel_token: tokio_util::sync::CancellationToken,
|
||||
) -> eyre::Result<()> {
|
||||
let remote_addr = maybe_addr!(tx.connection().remote_addr());
|
||||
|
||||
@@ -45,13 +47,32 @@ async fn stream_file_bytes(
|
||||
let mut buf = vec![0u8; FILE_TRANSFER_BUFFER_SIZE];
|
||||
|
||||
while remaining > 0 {
|
||||
if cancel_token.is_cancelled() {
|
||||
log::info!(
|
||||
"{remote_addr} transfer cancelled for {}",
|
||||
validated_path.display()
|
||||
);
|
||||
return Err(eyre::eyre!("File transfer cancelled by user"));
|
||||
}
|
||||
|
||||
let read_len = std::cmp::min(remaining, buf.len() as u64);
|
||||
let read_len: usize = read_len.try_into().unwrap_or(usize::MAX);
|
||||
if read_len == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let bytes_read = file.read(&mut buf[..read_len]).await?;
|
||||
let bytes_read = tokio::select! {
|
||||
() = cancel_token.cancelled() => {
|
||||
log::info!(
|
||||
"{remote_addr} transfer cancelled for {}",
|
||||
validated_path.display()
|
||||
);
|
||||
return Err(eyre::eyre!("File transfer cancelled by user"));
|
||||
}
|
||||
res = file.read(&mut buf[..read_len]) => {
|
||||
res?
|
||||
}
|
||||
};
|
||||
if bytes_read == 0 {
|
||||
if !expect_exact {
|
||||
transfer_complete = true;
|
||||
@@ -59,7 +80,18 @@ async fn stream_file_bytes(
|
||||
break;
|
||||
}
|
||||
|
||||
tx.send(Bytes::copy_from_slice(&buf[..bytes_read])).await?;
|
||||
tokio::select! {
|
||||
() = cancel_token.cancelled() => {
|
||||
log::info!(
|
||||
"{remote_addr} transfer cancelled for {}",
|
||||
validated_path.display()
|
||||
);
|
||||
return Err(eyre::eyre!("File transfer cancelled by user"));
|
||||
}
|
||||
res = tx.send(Bytes::copy_from_slice(&buf[..bytes_read])) => {
|
||||
res?;
|
||||
}
|
||||
}
|
||||
remaining = remaining.saturating_sub(bytes_read as u64);
|
||||
total_bytes += bytes_read as u64;
|
||||
|
||||
@@ -97,12 +129,20 @@ async fn stream_file_bytes(
|
||||
validated_path.display()
|
||||
);
|
||||
|
||||
match tx.close().await {
|
||||
Ok(()) => {}
|
||||
Err(err) if transfer_complete && is_clean_remote_close(&err) => {
|
||||
log::debug!("{remote_addr} closed stream after transfer completion: {err}");
|
||||
tokio::select! {
|
||||
() = cancel_token.cancelled() => {
|
||||
log::info!("{remote_addr} transfer cancelled while closing stream");
|
||||
return Err(eyre::eyre!("File transfer cancelled by user"));
|
||||
}
|
||||
res = tx.close() => {
|
||||
match res {
|
||||
Ok(()) => {}
|
||||
Err(err) if transfer_complete && is_clean_remote_close(&err) => {
|
||||
log::debug!("{remote_addr} closed stream after transfer completion: {err}");
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -121,8 +161,18 @@ pub async fn send_game_file_data(
|
||||
game_file_desc: &GameFileDescription,
|
||||
tx: &mut SendStream,
|
||||
game_dir: &Path,
|
||||
cancel_token: tokio_util::sync::CancellationToken,
|
||||
) {
|
||||
if let Err(e) = stream_file_bytes(tx, game_dir, &game_file_desc.relative_path, 0, None).await {
|
||||
if let Err(e) = stream_file_bytes(
|
||||
tx,
|
||||
game_dir,
|
||||
&game_file_desc.relative_path,
|
||||
0,
|
||||
None,
|
||||
cancel_token,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let remote_addr = maybe_addr!(tx.connection().remote_addr());
|
||||
log::error!(
|
||||
"{remote_addr} failed to stream file {}: {e}",
|
||||
@@ -138,8 +188,18 @@ pub async fn send_game_file_chunk(
|
||||
length: u64,
|
||||
tx: &mut SendStream,
|
||||
game_dir: &Path,
|
||||
cancel_token: tokio_util::sync::CancellationToken,
|
||||
) {
|
||||
if let Err(e) = stream_file_bytes(tx, game_dir, relative_path, offset, Some(length)).await {
|
||||
if let Err(e) = stream_file_bytes(
|
||||
tx,
|
||||
game_dir,
|
||||
relative_path,
|
||||
offset,
|
||||
Some(length),
|
||||
cancel_token,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let remote_addr = maybe_addr!(tx.connection().remote_addr());
|
||||
log::error!(
|
||||
"{remote_addr} failed to stream chunk {game_id}/{relative_path} offset {offset} length {length}: {e}"
|
||||
|
||||
Reference in New Issue
Block a user