Keep one queued and one replaceable pending snapshot for remote-library and Call-to-Play views while preserving lifecycle-event FIFO delivery. Generation barriers and fenced drains prevent stale nonempty views from crossing disable or acknowledgement boundaries. Test Plan: - just test - just clippy - focused burst, lifecycle ordering, fence, repeated-barrier, and stale-view tests - independent ordering review - git diff --check
719 lines
25 KiB
Rust
719 lines
25 KiB
Rust
use std::{
|
|
collections::{HashMap, HashSet},
|
|
fmt,
|
|
net::SocketAddr,
|
|
path::Path,
|
|
sync::Arc,
|
|
};
|
|
|
|
use futures::stream::FuturesUnordered;
|
|
use lanspread_db::content_manifest::ContentId;
|
|
use lanspread_proto::PeerEndpoint;
|
|
use crate::PeerEventSender;
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
use super::{
|
|
DownloadTransferError,
|
|
DownloadTransferErrorKind,
|
|
confined_fs::ConfinedGameRoot,
|
|
manifest::{ValidatedDownloadEntry, ValidatedDownloadManifest},
|
|
ownership::{DownloadOwnershipTransaction, OwnershipJournalPublication},
|
|
planning::{
|
|
ChunkDownloadResult,
|
|
DownloadChunk,
|
|
PeerDownloadPlan,
|
|
build_peer_plans,
|
|
reconcile_chunk_results,
|
|
},
|
|
progress::{DownloadProgressTracker, sample_download_progress},
|
|
retry::{RetryChunk, RetryContext, quarantine_if_integrity_failure, retry_failed_chunks},
|
|
storage::{prepare_game_storage, sync_game_storage},
|
|
task_drain::collect_or_drain_on_cancel,
|
|
transport::{PeerDownloadRequest, download_from_peer},
|
|
version_ini::{
|
|
VersionIniBuffer,
|
|
VersionIniCommit,
|
|
begin_version_ini_transaction,
|
|
commit_version_ini_buffer,
|
|
restore_unjournaled_version_ini_transaction,
|
|
},
|
|
};
|
|
use crate::{
|
|
DownloadFailureReason,
|
|
DownloadVerificationActivity,
|
|
PeerEvent,
|
|
content_quarantine::ContentQuarantine,
|
|
peer_db::PeerId,
|
|
quic_runtime::QuicConnector,
|
|
transfer_status::{DownloadAttemptReporter, DownloadAttemptStatus},
|
|
};
|
|
|
|
/// Terminal state of a complete payload transfer.
|
|
#[derive(Debug)]
|
|
pub(crate) enum DownloadCompletion {
|
|
/// Payload, sentinel, and ownership state are durably settled.
|
|
Durable,
|
|
/// The committed payload is visible, but recovery must settle its metadata
|
|
/// before it can be advertised, served, or installed.
|
|
RecoveryRequired(eyre::Report),
|
|
}
|
|
|
|
/// Typed owner-facing failure from one complete ordinary download operation.
|
|
#[derive(Debug)]
|
|
pub(crate) struct DownloadOperationError {
|
|
reason: Option<DownloadFailureReason>,
|
|
error: eyre::Report,
|
|
}
|
|
|
|
impl DownloadOperationError {
|
|
pub(super) fn cancelled(error: impl Into<eyre::Report>) -> Self {
|
|
Self {
|
|
reason: None,
|
|
error: error.into(),
|
|
}
|
|
}
|
|
|
|
pub(super) fn sources_exhausted(error: impl Into<eyre::Report>) -> Self {
|
|
Self {
|
|
reason: Some(DownloadFailureReason::VerifiedCatalogSourcesExhausted),
|
|
error: error.into(),
|
|
}
|
|
}
|
|
|
|
pub(super) fn operation_failed(error: impl Into<eyre::Report>) -> Self {
|
|
Self {
|
|
reason: Some(DownloadFailureReason::OperationFailed),
|
|
error: error.into(),
|
|
}
|
|
}
|
|
|
|
pub(crate) const fn reason(&self) -> Option<DownloadFailureReason> {
|
|
self.reason
|
|
}
|
|
|
|
pub(crate) fn into_report(self) -> eyre::Report {
|
|
self.error
|
|
}
|
|
}
|
|
|
|
/// Aggregates independent chunk failures without letting a later retryable
|
|
/// source failure downgrade an already-observed local operation failure.
|
|
#[derive(Default)]
|
|
struct TransferFailureAggregate {
|
|
operation_failed: Option<DownloadTransferError>,
|
|
cancelled: Option<DownloadTransferError>,
|
|
sources_exhausted: Option<DownloadTransferError>,
|
|
}
|
|
|
|
impl TransferFailureAggregate {
|
|
fn record(&mut self, error: DownloadTransferError) {
|
|
match error.kind() {
|
|
DownloadTransferErrorKind::LocalIo => {
|
|
self.operation_failed.get_or_insert(error);
|
|
}
|
|
DownloadTransferErrorKind::Cancelled => {
|
|
self.cancelled.get_or_insert(error);
|
|
}
|
|
DownloadTransferErrorKind::Integrity | DownloadTransferErrorKind::Transport => {
|
|
self.sources_exhausted.get_or_insert(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn into_operation_error(self) -> Option<DownloadOperationError> {
|
|
if let Some(error) = self.operation_failed {
|
|
return Some(DownloadOperationError::operation_failed(error));
|
|
}
|
|
if let Some(error) = self.cancelled {
|
|
return Some(DownloadOperationError::cancelled(error));
|
|
}
|
|
self.sources_exhausted
|
|
.map(DownloadOperationError::sources_exhausted)
|
|
}
|
|
|
|
fn cancellation_error(&mut self, game_id: &str) -> DownloadOperationError {
|
|
self.operation_failed.take().map_or_else(
|
|
|| cancelled_download(game_id),
|
|
DownloadOperationError::operation_failed,
|
|
)
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for DownloadOperationError {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
self.error.fmt(formatter)
|
|
}
|
|
}
|
|
|
|
/// Complete authority and runtime input for one ordinary catalog download.
|
|
pub(crate) struct DownloadGameRequest<'a> {
|
|
pub(crate) attempt: &'a DownloadAttemptStatus,
|
|
pub(crate) manifest: ValidatedDownloadManifest,
|
|
pub(crate) state_dir: &'a Path,
|
|
pub(crate) sources: &'a [PeerEndpoint],
|
|
pub(crate) content_id: ContentId,
|
|
pub(crate) quarantine: &'a ContentQuarantine,
|
|
pub(crate) tx_notify_ui: PeerEventSender,
|
|
pub(crate) cancel_token: CancellationToken,
|
|
pub(crate) quic: QuicConnector,
|
|
}
|
|
|
|
/// Downloads all game files from available peers.
|
|
#[allow(clippy::too_many_lines)]
|
|
pub(crate) async fn download_game_files(
|
|
request: DownloadGameRequest<'_>,
|
|
) -> Result<DownloadCompletion, DownloadOperationError> {
|
|
let DownloadGameRequest {
|
|
attempt,
|
|
manifest,
|
|
state_dir,
|
|
sources,
|
|
content_id,
|
|
quarantine,
|
|
tx_notify_ui,
|
|
cancel_token,
|
|
quic,
|
|
} = request;
|
|
let game_id = manifest.game_id().to_owned();
|
|
let manifest_content_id = manifest.catalog_manifest().content_id();
|
|
if manifest_content_id != content_id {
|
|
return Err(DownloadOperationError::operation_failed(eyre::eyre!(
|
|
"download content ID does not match catalog authority for game {game_id}: requested {content_id}, catalog {manifest_content_id}"
|
|
)));
|
|
}
|
|
let sources = eligible_content_sources(sources, content_id, quarantine)
|
|
.map_err(DownloadOperationError::operation_failed)?;
|
|
if sources.is_empty() {
|
|
return Err(DownloadOperationError::sources_exhausted(eyre::eyre!(
|
|
"no peers available for game {game_id}"
|
|
)));
|
|
}
|
|
|
|
if cancel_token.is_cancelled() {
|
|
return Err(cancelled_download(&game_id));
|
|
}
|
|
|
|
let version_entry = manifest.version_entry();
|
|
let version_buffer = match VersionIniBuffer::new(
|
|
version_entry.destination().canonical(),
|
|
version_entry.size(),
|
|
) {
|
|
Ok(buffer) => Arc::new(buffer),
|
|
Err(err) => return Err(DownloadOperationError::operation_failed(err)),
|
|
};
|
|
let confined_root = ConfinedGameRoot::open_or_create(manifest.games_folder(), &game_id)
|
|
.map_err(DownloadOperationError::operation_failed)?;
|
|
let ownership = DownloadOwnershipTransaction::prepare(state_dir, &manifest, &confined_root)
|
|
.await
|
|
.map_err(DownloadOperationError::operation_failed)?;
|
|
|
|
if let Err(error) = begin_version_ini_transaction(&confined_root) {
|
|
if let Err(restore_error) = restore_before_ownership_journal(&confined_root) {
|
|
return Err(DownloadOperationError::operation_failed(error.wrap_err(
|
|
format!("sentinel parking failed and rollback also failed: {restore_error}"),
|
|
)));
|
|
}
|
|
return Err(DownloadOperationError::operation_failed(error));
|
|
}
|
|
if cancel_token.is_cancelled() {
|
|
restore_before_ownership_journal(&confined_root)
|
|
.map_err(DownloadOperationError::operation_failed)?;
|
|
return Err(cancelled_download(&game_id));
|
|
}
|
|
match ownership.journal_pending().await {
|
|
Ok(OwnershipJournalPublication::Durable) => {}
|
|
Ok(OwnershipJournalPublication::NeedsRecovery(error)) => {
|
|
// The pending record is visible, so restoring the old sentinel
|
|
// would make recovery mistake it for a landed new commit. Stop
|
|
// before payload mutation and leave the phase unambiguous.
|
|
return Err(DownloadOperationError::operation_failed(eyre::eyre!(
|
|
"pending download ownership was renamed but its durability could not be established: {error}"
|
|
)));
|
|
}
|
|
Err(error) => {
|
|
if let Err(restore_error) = restore_before_ownership_journal(&confined_root) {
|
|
return Err(DownloadOperationError::operation_failed(error.wrap_err(
|
|
format!(
|
|
"ownership journal failed and sentinel restore also failed: {restore_error}"
|
|
),
|
|
)));
|
|
}
|
|
return Err(DownloadOperationError::operation_failed(error));
|
|
}
|
|
}
|
|
if let Err(err) = prepare_game_storage(&manifest, &confined_root) {
|
|
let failure = DownloadOperationError::operation_failed(err);
|
|
return Err(abort_download(&ownership, &game_id, failure).await);
|
|
}
|
|
if cancel_token.is_cancelled() {
|
|
let failure = cancelled_download(&game_id);
|
|
return Err(abort_download(&ownership, &game_id, failure).await);
|
|
}
|
|
|
|
let progress_tracker = DownloadProgressTracker::new(total_download_bytes(manifest.entries()));
|
|
let attempt_reporter = attempt.reporter();
|
|
let transfer_ctx = TransferContext {
|
|
game_id: &game_id,
|
|
game_root: &confined_root,
|
|
sources: &sources,
|
|
content_id,
|
|
quarantine,
|
|
tx_notify_ui: &tx_notify_ui,
|
|
cancel_token: &cancel_token,
|
|
quic: &quic,
|
|
version_buffer: version_buffer.clone(),
|
|
progress_tracker: progress_tracker.clone(),
|
|
attempt: attempt_reporter.clone(),
|
|
};
|
|
let plans = match build_initial_transfer_plans(&transfer_ctx, &manifest) {
|
|
Ok(plans) => plans,
|
|
Err(error) => {
|
|
attempt.close_source_admission();
|
|
return Err(abort_download(&ownership, &game_id, error).await);
|
|
}
|
|
};
|
|
attempt.emit_begin();
|
|
attempt.set_activity(DownloadVerificationActivity::VerifyingDownloadedChunks);
|
|
let transfer_result = sample_download_progress(
|
|
attempt_reporter,
|
|
progress_tracker,
|
|
download_transfer_chunks(&transfer_ctx, plans),
|
|
)
|
|
.await;
|
|
attempt.close_source_admission();
|
|
attempt.clear_activity();
|
|
|
|
if let Err(err) = transfer_result {
|
|
return Err(abort_download(&ownership, &game_id, err).await);
|
|
}
|
|
|
|
if cancel_token.is_cancelled() {
|
|
let failure = cancelled_download(&game_id);
|
|
return Err(abort_download(&ownership, &game_id, failure).await);
|
|
}
|
|
|
|
if let Err(error) = sync_game_storage(&manifest, &confined_root) {
|
|
let failure = DownloadOperationError::operation_failed(
|
|
error.wrap_err("failed to make downloaded payload durable"),
|
|
);
|
|
return Err(abort_download(&ownership, &game_id, failure).await);
|
|
}
|
|
if cancel_token.is_cancelled() {
|
|
let failure = cancelled_download(&game_id);
|
|
return Err(abort_download(&ownership, &game_id, failure).await);
|
|
}
|
|
|
|
if let Err(error) = ownership.remove_stale() {
|
|
let failure = DownloadOperationError::operation_failed(
|
|
error.wrap_err("failed to remove stale download-owned files"),
|
|
);
|
|
return Err(abort_download(&ownership, &game_id, failure).await);
|
|
}
|
|
if cancel_token.is_cancelled() {
|
|
let failure = cancelled_download(&game_id);
|
|
return Err(abort_download(&ownership, &game_id, failure).await);
|
|
}
|
|
|
|
match commit_version_ini_buffer(&confined_root, &version_buffer).await {
|
|
Ok(VersionIniCommit::Durable) => {}
|
|
Ok(VersionIniCommit::NeedsRecovery(error)) => {
|
|
// The visible sentinel makes rollback unsafe. Keep pending ownership
|
|
// so startup recovery can decide from the durable filesystem state.
|
|
return Ok(DownloadCompletion::RecoveryRequired(eyre::eyre!(
|
|
"version.ini was renamed but its durability could not be established: {error}"
|
|
)));
|
|
}
|
|
Err(error) => {
|
|
let failure = DownloadOperationError::operation_failed(error);
|
|
return Err(abort_download(&ownership, &game_id, failure).await);
|
|
}
|
|
}
|
|
match ownership.finalize().await {
|
|
Ok(OwnershipJournalPublication::Durable) => {}
|
|
Ok(OwnershipJournalPublication::NeedsRecovery(error)) => {
|
|
return Ok(DownloadCompletion::RecoveryRequired(error.wrap_err(
|
|
"downloaded payload is visible, but ownership durability must be recovered",
|
|
)));
|
|
}
|
|
Err(error) => {
|
|
return Ok(DownloadCompletion::RecoveryRequired(error.wrap_err(
|
|
"downloaded payload is visible, but ownership finalization must be recovered",
|
|
)));
|
|
}
|
|
}
|
|
log::info!("all files downloaded for game: {game_id}");
|
|
Ok(DownloadCompletion::Durable)
|
|
}
|
|
|
|
fn restore_before_ownership_journal(game_root: &ConfinedGameRoot) -> eyre::Result<()> {
|
|
restore_unjournaled_version_ini_transaction(game_root)
|
|
}
|
|
|
|
fn cancelled_download(game_id: &str) -> DownloadOperationError {
|
|
DownloadOperationError::cancelled(eyre::eyre!("download cancelled for game {game_id}"))
|
|
}
|
|
|
|
async fn abort_download(
|
|
ownership: &DownloadOwnershipTransaction,
|
|
game_id: &str,
|
|
failure: DownloadOperationError,
|
|
) -> DownloadOperationError {
|
|
match ownership.abort().await {
|
|
Ok(()) => failure,
|
|
Err(abort_error) => DownloadOperationError::operation_failed(eyre::eyre!(
|
|
"download failed for {game_id}: {failure}; ownership rollback also failed: {abort_error}"
|
|
)),
|
|
}
|
|
}
|
|
|
|
struct TransferContext<'a> {
|
|
game_id: &'a str,
|
|
game_root: &'a ConfinedGameRoot,
|
|
sources: &'a [PeerEndpoint],
|
|
content_id: ContentId,
|
|
quarantine: &'a ContentQuarantine,
|
|
tx_notify_ui: &'a PeerEventSender,
|
|
cancel_token: &'a CancellationToken,
|
|
quic: &'a QuicConnector,
|
|
version_buffer: Arc<VersionIniBuffer>,
|
|
progress_tracker: Arc<DownloadProgressTracker>,
|
|
attempt: DownloadAttemptReporter,
|
|
}
|
|
|
|
struct InitialAttempt {
|
|
source: PeerEndpoint,
|
|
planned_chunks: Vec<DownloadChunk>,
|
|
result: Result<Vec<ChunkDownloadResult>, DownloadTransferError>,
|
|
}
|
|
|
|
fn eligible_content_sources(
|
|
sources: &[PeerEndpoint],
|
|
content_id: ContentId,
|
|
quarantine: &ContentQuarantine,
|
|
) -> eyre::Result<Vec<PeerEndpoint>> {
|
|
let mut seen_peer_ids = HashSet::<PeerId>::new();
|
|
let mut peer_by_addr = HashMap::<SocketAddr, PeerId>::new();
|
|
let mut eligible = Vec::with_capacity(sources.len());
|
|
|
|
for source in sources {
|
|
if !seen_peer_ids.insert(source.peer_id) {
|
|
continue;
|
|
}
|
|
if let Some(previous_peer_id) = peer_by_addr.insert(source.addr, source.peer_id) {
|
|
eyre::bail!(
|
|
"content source address {} is ambiguously assigned to peers {previous_peer_id} and {}",
|
|
source.addr,
|
|
source.peer_id
|
|
);
|
|
}
|
|
if !quarantine.is_quarantined(source, content_id) {
|
|
eligible.push(*source);
|
|
}
|
|
}
|
|
|
|
Ok(eligible)
|
|
}
|
|
|
|
async fn download_transfer_chunks(
|
|
ctx: &TransferContext<'_>,
|
|
plans: HashMap<PeerEndpoint, PeerDownloadPlan>,
|
|
) -> Result<(), DownloadOperationError> {
|
|
let tasks = FuturesUnordered::new();
|
|
for (endpoint, plan) in plans {
|
|
let source = endpoint;
|
|
let planned_chunks = plan.chunks.clone();
|
|
let game_root = ctx.game_root.clone();
|
|
let game_id = ctx.game_id.to_string();
|
|
let cancel_token = ctx.cancel_token.clone();
|
|
let version_buffer = ctx.version_buffer.clone();
|
|
let progress_tracker = ctx.progress_tracker.clone();
|
|
let quic = ctx.quic.clone();
|
|
tasks.push(async move {
|
|
let result = download_from_peer(PeerDownloadRequest {
|
|
quic,
|
|
endpoint,
|
|
game_id,
|
|
plan,
|
|
game_root,
|
|
cancel_token,
|
|
version_buffer,
|
|
progress_tracker,
|
|
})
|
|
.await;
|
|
InitialAttempt {
|
|
source,
|
|
planned_chunks,
|
|
result,
|
|
}
|
|
});
|
|
}
|
|
|
|
let mut failed_chunks = Vec::new();
|
|
let mut failures = TransferFailureAggregate::default();
|
|
|
|
let attempts = collect_or_drain_on_cancel(tasks, ctx.cancel_token, ctx.game_id)
|
|
.await
|
|
.map_err(DownloadOperationError::cancelled)?;
|
|
for attempt in attempts {
|
|
if ctx.cancel_token.is_cancelled() {
|
|
return Err(failures.cancellation_error(ctx.game_id));
|
|
}
|
|
collect_initial_attempt(ctx, attempt, &mut failed_chunks, &mut failures)
|
|
.map_err(DownloadOperationError::operation_failed)?;
|
|
}
|
|
|
|
if !failed_chunks.is_empty() {
|
|
retry_chunks(ctx, failed_chunks, &mut failures).await?;
|
|
}
|
|
|
|
if ctx.cancel_token.is_cancelled() {
|
|
return Err(failures.cancellation_error(ctx.game_id));
|
|
}
|
|
|
|
if let Some(error) = failures.into_operation_error() {
|
|
return Err(error);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn build_initial_transfer_plans(
|
|
ctx: &TransferContext<'_>,
|
|
manifest: &ValidatedDownloadManifest,
|
|
) -> Result<HashMap<PeerEndpoint, PeerDownloadPlan>, DownloadOperationError> {
|
|
let sources = eligible_content_sources(ctx.sources, ctx.content_id, ctx.quarantine)
|
|
.map_err(DownloadOperationError::operation_failed)?;
|
|
if sources.is_empty() {
|
|
return Err(DownloadOperationError::sources_exhausted(eyre::eyre!(
|
|
"no nonquarantined sources remain for game {}",
|
|
ctx.game_id
|
|
)));
|
|
}
|
|
|
|
// Local catalog identity defines the exact content and canonical path carried
|
|
// by every request. Planning only distributes those immutable chunks over
|
|
// authenticated, exact-content, quarantine-filtered endpoints.
|
|
let plans =
|
|
build_peer_plans(&sources, manifest).map_err(DownloadOperationError::operation_failed)?;
|
|
if plans.is_empty() {
|
|
return Err(DownloadOperationError::operation_failed(eyre::eyre!(
|
|
"catalog download plan contains no chunks for game {}",
|
|
ctx.game_id
|
|
)));
|
|
}
|
|
Ok(plans)
|
|
}
|
|
|
|
fn collect_initial_attempt(
|
|
ctx: &TransferContext<'_>,
|
|
attempt: InitialAttempt,
|
|
failed_chunks: &mut Vec<RetryChunk>,
|
|
failures: &mut TransferFailureAggregate,
|
|
) -> eyre::Result<()> {
|
|
let InitialAttempt {
|
|
source,
|
|
planned_chunks,
|
|
result,
|
|
} = attempt;
|
|
match result {
|
|
Ok(results) => {
|
|
let expected_endpoint = source;
|
|
for (planned_chunk, mut result) in reconcile_chunk_results(
|
|
planned_chunks,
|
|
results,
|
|
expected_endpoint,
|
|
|chunk| chunk,
|
|
"initial download",
|
|
)? {
|
|
result.chunk = planned_chunk;
|
|
collect_initial_chunk_result(ctx, &source, result, failed_chunks, failures);
|
|
}
|
|
}
|
|
Err(error) => {
|
|
for chunk in planned_chunks {
|
|
collect_initial_chunk_result(
|
|
ctx,
|
|
&source,
|
|
ChunkDownloadResult {
|
|
chunk,
|
|
result: Err(error.clone()),
|
|
peer_endpoint: source,
|
|
},
|
|
failed_chunks,
|
|
failures,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn collect_initial_chunk_result(
|
|
ctx: &TransferContext<'_>,
|
|
source: &PeerEndpoint,
|
|
result: ChunkDownloadResult,
|
|
failed_chunks: &mut Vec<RetryChunk>,
|
|
failures: &mut TransferFailureAggregate,
|
|
) {
|
|
match result.result {
|
|
Ok(()) => notify_chunk_finished(ctx, &result.chunk, result.peer_endpoint),
|
|
Err(error) => {
|
|
log::warn!("Failed to download chunk from {}: {error}", source.addr);
|
|
quarantine_if_integrity_failure(ctx.quarantine, source, ctx.content_id, &error);
|
|
match error.kind() {
|
|
DownloadTransferErrorKind::Integrity | DownloadTransferErrorKind::Transport => {
|
|
failed_chunks.push(RetryChunk::after_failure(result.chunk, *source, error));
|
|
}
|
|
DownloadTransferErrorKind::LocalIo | DownloadTransferErrorKind::Cancelled => {
|
|
failures.record(error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn notify_chunk_finished(
|
|
ctx: &TransferContext<'_>,
|
|
chunk: &DownloadChunk,
|
|
peer_endpoint: PeerEndpoint,
|
|
) {
|
|
let _ = ctx
|
|
.tx_notify_ui
|
|
.send(PeerEvent::DownloadGameFileChunkFinished {
|
|
id: ctx.game_id.to_string(),
|
|
peer_id: peer_endpoint.peer_id,
|
|
peer_addr: peer_endpoint.addr,
|
|
content_id: chunk.content_id,
|
|
relative_path: chunk.canonical_path().clone(),
|
|
offset: chunk.offset,
|
|
length: chunk.length,
|
|
});
|
|
}
|
|
|
|
async fn retry_chunks(
|
|
ctx: &TransferContext<'_>,
|
|
failed_chunks: Vec<RetryChunk>,
|
|
failures: &mut TransferFailureAggregate,
|
|
) -> Result<(), DownloadOperationError> {
|
|
if ctx.cancel_token.is_cancelled() {
|
|
return Err(failures.cancellation_error(ctx.game_id));
|
|
}
|
|
|
|
log::info!("Retrying {} failed chunks", failed_chunks.len());
|
|
|
|
let retry_ctx = RetryContext {
|
|
sources: ctx.sources,
|
|
content_id: ctx.content_id,
|
|
quarantine: ctx.quarantine,
|
|
game_root: ctx.game_root,
|
|
game_id: ctx.game_id,
|
|
cancel_token: ctx.cancel_token,
|
|
quic: ctx.quic,
|
|
version_buffer: ctx.version_buffer.clone(),
|
|
progress_tracker: ctx.progress_tracker.clone(),
|
|
attempt: ctx.attempt.clone(),
|
|
};
|
|
let retry_results = match retry_failed_chunks(failed_chunks, &retry_ctx).await {
|
|
Ok(results) => results,
|
|
Err(_) if ctx.cancel_token.is_cancelled() => {
|
|
return Err(failures.cancellation_error(ctx.game_id));
|
|
}
|
|
Err(err) => {
|
|
return Err(DownloadOperationError::operation_failed(err));
|
|
}
|
|
};
|
|
|
|
for chunk_result in retry_results {
|
|
if ctx.cancel_token.is_cancelled() {
|
|
return Err(failures.cancellation_error(ctx.game_id));
|
|
}
|
|
|
|
match chunk_result.result {
|
|
Ok(()) => {
|
|
notify_chunk_finished(ctx, &chunk_result.chunk, chunk_result.peer_endpoint);
|
|
}
|
|
Err(e) => {
|
|
log::error!("Retry failed for chunk: {e}");
|
|
failures.record(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn total_download_bytes(file_descs: &[ValidatedDownloadEntry]) -> u64 {
|
|
file_descs
|
|
.iter()
|
|
.filter(|entry| !entry.is_dir())
|
|
.fold(0u64, |total, entry| total.saturating_add(entry.size()))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn source(peer_id: &str, port: u16) -> PeerEndpoint {
|
|
PeerEndpoint::new(
|
|
PeerId::from_bytes(*blake3::hash(peer_id.as_bytes()).as_bytes()),
|
|
SocketAddr::from(([127, 0, 0, 1], port)),
|
|
)
|
|
}
|
|
|
|
fn content(seed: u8) -> ContentId {
|
|
ContentId::from_bytes([seed; 32])
|
|
}
|
|
|
|
#[test]
|
|
fn initial_source_filter_skips_bad_source_and_keeps_good_source() {
|
|
let bad = source("bad", 12000);
|
|
let good = source("good", 12001);
|
|
let sources = vec![bad, good];
|
|
let quarantine = ContentQuarantine::default();
|
|
let content_id = content(1);
|
|
quarantine.record_integrity_failure(&bad, content_id);
|
|
|
|
let eligible = eligible_content_sources(&sources, content_id, &quarantine)
|
|
.expect("unambiguous content sources should validate");
|
|
|
|
assert_eq!(eligible, vec![good]);
|
|
}
|
|
|
|
#[test]
|
|
fn initial_source_filter_rejects_ambiguous_address_identity() {
|
|
let sources = vec![source("first", 12000), source("second", 12000)];
|
|
|
|
let error = eligible_content_sources(&sources, content(2), &ContentQuarantine::default())
|
|
.expect_err("one address must not represent two authenticated identities");
|
|
|
|
assert!(error.to_string().contains("ambiguously assigned"));
|
|
}
|
|
|
|
#[test]
|
|
fn local_failure_is_not_downgraded_by_later_retryable_exhaustion() {
|
|
let mut failures = TransferFailureAggregate::default();
|
|
failures.record(DownloadTransferError::local_io("destination write failed"));
|
|
failures.record(DownloadTransferError::transport(
|
|
"retry source disconnected",
|
|
));
|
|
|
|
let error = failures
|
|
.into_operation_error()
|
|
.expect("recorded failures should produce an operation error");
|
|
|
|
assert_eq!(error.reason(), Some(DownloadFailureReason::OperationFailed));
|
|
assert_eq!(error.to_string(), "destination write failed");
|
|
}
|
|
|
|
#[test]
|
|
fn local_failure_is_not_downgraded_by_later_cancellation() {
|
|
let mut failures = TransferFailureAggregate::default();
|
|
failures.record(DownloadTransferError::local_io("destination write failed"));
|
|
|
|
let error = failures.cancellation_error("game");
|
|
|
|
assert_eq!(error.reason(), Some(DownloadFailureReason::OperationFailed));
|
|
assert_eq!(error.to_string(), "destination write failed");
|
|
}
|
|
}
|