//! Attempt-keyed download status and cancellation coordination. use std::{ fmt, sync::{ Arc, Mutex, atomic::{AtomicU64, Ordering}, }, }; use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use tokio::sync::mpsc::UnboundedSender; use tokio_util::sync::CancellationToken; use crate::{DownloadProgress, PeerEvent, events}; static NEXT_DOWNLOAD_ATTEMPT_ID: AtomicU64 = AtomicU64::new(1); /// Process-local monotonic identity for one download command attempt. /// /// The integer is deliberately serialized as a decimal string so JavaScript /// consumers never truncate it through IEEE-754 number conversion. #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct DownloadAttemptId(u64); impl DownloadAttemptId { fn next() -> Self { let value = NEXT_DOWNLOAD_ATTEMPT_ID .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { current.checked_add(1) }) .expect("download attempt ID space exhausted"); Self(value) } } impl fmt::Display for DownloadAttemptId { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(formatter) } } impl Serialize for DownloadAttemptId { fn serialize(&self, serializer: S) -> Result where S: Serializer, { serializer.collect_str(self) } } struct DownloadAttemptIdVisitor; impl de::Visitor<'_> for DownloadAttemptIdVisitor { type Value = DownloadAttemptId; fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str("an unsigned 64-bit download attempt ID encoded as a decimal string") } fn visit_str(self, value: &str) -> Result where E: de::Error, { if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) || (value.len() > 1 && value.starts_with('0')) { return Err(E::invalid_value(de::Unexpected::Str(value), &self)); } value .parse::() .map(DownloadAttemptId) .map_err(|_| E::invalid_value(de::Unexpected::Str(value), &self)) } } impl<'de> Deserialize<'de> for DownloadAttemptId { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { deserializer.deserialize_str(DownloadAttemptIdVisitor) } } /// Stable correlation key carried by every non-diagnostic download event. #[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct DownloadAttemptKey { pub id: String, pub attempt_id: DownloadAttemptId, } impl DownloadAttemptKey { pub(crate) fn next(id: String) -> Self { Self { id, attempt_id: DownloadAttemptId::next(), } } } /// Nonterminal verification activity shown for one download attempt. #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] pub enum DownloadVerificationActivity { VerifyingDownloadedChunks, RetryingInvalidSource, } /// Stable terminal failure classification for one download attempt. #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] pub enum DownloadFailureReason { VerifiedCatalogSourcesExhausted, OperationFailed, } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] enum AttemptLifecycle { #[default] Open, SourceClosed, UserCancelled, SourcesExhausted, OwnerDone, } #[derive(Debug, Default)] struct AttemptRuntimeState { begin_emitted: bool, activity: Option, retry_activity_emitted: bool, lifecycle: AttemptLifecycle, terminal_emitted: bool, } struct DownloadAttemptState { key: DownloadAttemptKey, cancellation: CancellationToken, tx_notify_ui: UnboundedSender, runtime: Mutex, } /// Owner-side status handle retained for the complete structured operation. pub(crate) struct DownloadAttemptStatus { state: Arc, } impl DownloadAttemptStatus { pub(crate) fn new( key: DownloadAttemptKey, cancellation: CancellationToken, tx_notify_ui: UnboundedSender, ) -> Self { Self { state: Arc::new(DownloadAttemptState { key, cancellation, tx_notify_ui, runtime: Mutex::new(AttemptRuntimeState::default()), }), } } pub(crate) fn key(&self) -> &DownloadAttemptKey { &self.state.key } pub(crate) fn signal(&self) -> ActiveDownloadSignal { ActiveDownloadSignal { state: Arc::clone(&self.state), } } pub(crate) fn reporter(&self) -> DownloadAttemptReporter { DownloadAttemptReporter { state: Arc::clone(&self.state), } } pub(crate) fn emit_begin(&self) -> bool { let mut runtime = self.lock_runtime(); if runtime.begin_emitted || runtime.terminal_emitted { return false; } runtime.begin_emitted = true; events::send( &self.state.tx_notify_ui, PeerEvent::DownloadGameFilesBegin { attempt: self.state.key.clone(), }, ); true } pub(crate) fn set_activity(&self, activity: DownloadVerificationActivity) -> bool { set_activity(&self.state, activity) } pub(crate) fn clear_activity(&self) -> bool { let mut runtime = self.lock_runtime(); if runtime.activity.take().is_none() { return false; } events::send( &self.state.tx_notify_ui, PeerEvent::DownloadGameFilesActivityChanged { attempt: self.state.key.clone(), activity: None, }, ); true } /// Closes liveness source-loss admission after all receive children drain. /// User cancellation remains accepted until terminal owner settlement. pub(crate) fn close_source_admission(&self) -> bool { let mut runtime = self.lock_runtime(); match runtime.lifecycle { AttemptLifecycle::Open if self.state.cancellation.is_cancelled() => { runtime.lifecycle = AttemptLifecycle::UserCancelled; false } AttemptLifecycle::Open => { runtime.lifecycle = AttemptLifecycle::SourceClosed; true } AttemptLifecycle::SourceClosed | AttemptLifecycle::UserCancelled | AttemptLifecycle::SourcesExhausted | AttemptLifecycle::OwnerDone => false, } } pub(crate) fn resolve_failure( &self, direct_reason: Option, ) -> Option { let runtime = self.lock_runtime(); match (direct_reason, runtime.lifecycle) { (Some(DownloadFailureReason::OperationFailed), _) => direct_reason, (_, AttemptLifecycle::UserCancelled | AttemptLifecycle::OwnerDone) | (None, AttemptLifecycle::Open | AttemptLifecycle::SourceClosed) => None, (Some(DownloadFailureReason::VerifiedCatalogSourcesExhausted), _) | (None, AttemptLifecycle::SourcesExhausted) => { Some(DownloadFailureReason::VerifiedCatalogSourcesExhausted) } } } pub(crate) fn emit_finished(&self) -> bool { self.emit_terminal(None) } pub(crate) fn emit_failed(&self, reason: DownloadFailureReason) -> bool { self.emit_terminal(Some(reason)) } fn emit_terminal(&self, reason: Option) -> bool { let mut runtime = self.lock_runtime(); if runtime.terminal_emitted { return false; } if runtime.activity.is_some() { log::error!( "Download attempt {} for {} reached a terminal state before activity was cleared", self.state.key.attempt_id, self.state.key.id ); runtime.activity = None; events::send( &self.state.tx_notify_ui, PeerEvent::DownloadGameFilesActivityChanged { attempt: self.state.key.clone(), activity: None, }, ); } runtime.terminal_emitted = true; runtime.lifecycle = AttemptLifecycle::OwnerDone; let event = match reason { Some(reason) => PeerEvent::DownloadGameFilesFailed { attempt: self.state.key.clone(), reason, }, None => PeerEvent::DownloadGameFilesFinished { attempt: self.state.key.clone(), }, }; events::send(&self.state.tx_notify_ui, event); true } fn lock_runtime(&self) -> std::sync::MutexGuard<'_, AttemptRuntimeState> { self.state .runtime .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } } impl Drop for DownloadAttemptStatus { fn drop(&mut self) { let clear_activity = { let mut runtime = self.lock_runtime(); let clear_activity = runtime.activity.take().is_some(); runtime.lifecycle = AttemptLifecycle::OwnerDone; clear_activity }; if clear_activity { events::send( &self.state.tx_notify_ui, PeerEvent::DownloadGameFilesActivityChanged { attempt: self.state.key.clone(), activity: None, }, ); } } } /// Cloneable, nonterminal-only view used by structured transfer children. #[derive(Clone)] pub(crate) struct DownloadAttemptReporter { state: Arc, } impl DownloadAttemptReporter { pub(crate) fn key(&self) -> &DownloadAttemptKey { &self.state.key } pub(crate) fn set_activity(&self, activity: DownloadVerificationActivity) -> bool { set_activity(&self.state, activity) } pub(crate) fn emit_progress(&self, progress: DownloadProgress) -> bool { emit_progress(&self.state, progress) } } fn set_activity(state: &DownloadAttemptState, activity: DownloadVerificationActivity) -> bool { let mut runtime = state .runtime .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if runtime.lifecycle != AttemptLifecycle::Open || runtime.terminal_emitted { return false; } if runtime.retry_activity_emitted { return false; } if activity == DownloadVerificationActivity::RetryingInvalidSource { runtime.retry_activity_emitted = true; } if runtime.activity == Some(activity) { return false; } runtime.activity = Some(activity); events::send( &state.tx_notify_ui, PeerEvent::DownloadGameFilesActivityChanged { attempt: state.key.clone(), activity: Some(activity), }, ); true } fn emit_progress(state: &DownloadAttemptState, progress: DownloadProgress) -> bool { let runtime = state .runtime .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if runtime.lifecycle != AttemptLifecycle::Open || runtime.terminal_emitted || progress.attempt != state.key { return false; } events::send( &state.tx_notify_ui, PeerEvent::DownloadGameFilesProgress(progress), ); true } /// Restricted signal retained in `Ctx` for cancellation authorities. /// /// It deliberately exposes no activity or terminal event methods; the owning /// operation remains solely responsible for clearing activity and publishing /// its one terminal outcome after drainage and settlement. #[derive(Clone)] pub(crate) struct ActiveDownloadSignal { state: Arc, } impl ActiveDownloadSignal { pub(crate) fn key(&self) -> &DownloadAttemptKey { &self.state.key } #[cfg(test)] fn cancellation(&self) -> &CancellationToken { &self.state.cancellation } pub(crate) fn cancel_silently(&self) -> bool { let mut runtime = self.lock_runtime(); match runtime.lifecycle { AttemptLifecycle::Open | AttemptLifecycle::SourceClosed => { if self.state.cancellation.is_cancelled() { runtime.lifecycle = AttemptLifecycle::UserCancelled; return false; } runtime.lifecycle = AttemptLifecycle::UserCancelled; self.state.cancellation.cancel(); true } AttemptLifecycle::UserCancelled | AttemptLifecycle::SourcesExhausted | AttemptLifecycle::OwnerDone => false, } } pub(crate) fn cancel_sources_exhausted(&self) -> bool { let mut runtime = self.lock_runtime(); match runtime.lifecycle { AttemptLifecycle::Open if self.state.cancellation.is_cancelled() => { runtime.lifecycle = AttemptLifecycle::UserCancelled; false } AttemptLifecycle::Open => { runtime.lifecycle = AttemptLifecycle::SourcesExhausted; self.state.cancellation.cancel(); true } AttemptLifecycle::SourceClosed | AttemptLifecycle::UserCancelled | AttemptLifecycle::SourcesExhausted | AttemptLifecycle::OwnerDone => false, } } fn lock_runtime(&self) -> std::sync::MutexGuard<'_, AttemptRuntimeState> { self.state .runtime .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } } #[cfg(test)] mod tests { use super::*; fn attempt() -> ( DownloadAttemptStatus, tokio::sync::mpsc::UnboundedReceiver, ) { let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); ( DownloadAttemptStatus::new( DownloadAttemptKey::next("game".to_owned()), CancellationToken::new(), tx, ), rx, ) } #[test] fn attempt_ids_are_monotonic_and_serialize_only_as_decimal_strings() { let first = DownloadAttemptKey::next("first".to_owned()).attempt_id; let second = DownloadAttemptKey::next("second".to_owned()).attempt_id; assert!(second > first); let encoded = serde_json::to_string(&first).expect("attempt ID should serialize"); assert_eq!(encoded, format!("\"{first}\"")); assert_eq!( serde_json::from_str::(&encoded) .expect("decimal string should deserialize"), first ); assert!(serde_json::from_str::("1").is_err()); assert!(serde_json::from_str::("\"01\"").is_err()); } #[test] fn activity_is_deduplicated_cleared_and_never_reopened_after_terminal() { let (attempt, mut rx) = attempt(); assert!(attempt.emit_begin()); assert!(attempt.set_activity(DownloadVerificationActivity::VerifyingDownloadedChunks)); assert!(!attempt.set_activity(DownloadVerificationActivity::VerifyingDownloadedChunks)); assert!(attempt.set_activity(DownloadVerificationActivity::RetryingInvalidSource)); assert!(!attempt.set_activity(DownloadVerificationActivity::RetryingInvalidSource)); assert!(!attempt.set_activity(DownloadVerificationActivity::VerifyingDownloadedChunks)); assert!(attempt.clear_activity()); assert!(!attempt.clear_activity()); assert!(attempt.emit_finished()); assert!(!attempt.emit_failed(DownloadFailureReason::OperationFailed)); assert!(!attempt.set_activity(DownloadVerificationActivity::VerifyingDownloadedChunks)); assert!(matches!( rx.try_recv(), Ok(PeerEvent::DownloadGameFilesBegin { .. }) )); assert!(matches!( rx.try_recv(), Ok(PeerEvent::DownloadGameFilesActivityChanged { activity: Some(DownloadVerificationActivity::VerifyingDownloadedChunks), .. }) )); assert!(matches!( rx.try_recv(), Ok(PeerEvent::DownloadGameFilesActivityChanged { activity: Some(DownloadVerificationActivity::RetryingInvalidSource), .. }) )); assert!(matches!( rx.try_recv(), Ok(PeerEvent::DownloadGameFilesActivityChanged { activity: None, .. }) )); assert!(matches!( rx.try_recv(), Ok(PeerEvent::DownloadGameFilesFinished { .. }) )); assert!(rx.try_recv().is_err()); } #[test] fn first_cancellation_cause_is_stable_and_operation_failure_dominates() { let (attempt, _rx) = attempt(); let signal = attempt.signal(); assert!(signal.cancel_sources_exhausted()); assert!(!signal.cancel_silently()); assert_eq!( attempt.resolve_failure(None), Some(DownloadFailureReason::VerifiedCatalogSourcesExhausted) ); assert_eq!( attempt.resolve_failure(Some(DownloadFailureReason::OperationFailed)), Some(DownloadFailureReason::OperationFailed) ); } #[test] fn silent_cancellation_cannot_be_reclassified_by_liveness() { let (attempt, _rx) = attempt(); let signal = attempt.signal(); assert!(signal.cancel_silently()); assert!(!signal.cancel_sources_exhausted()); assert_eq!(attempt.resolve_failure(None), None); } #[test] fn source_close_rejects_liveness_but_still_accepts_user_cancellation() { let (attempt, _rx) = attempt(); let signal = attempt.signal(); assert!(attempt.close_source_admission()); assert!(!signal.cancel_sources_exhausted()); assert!(signal.cancel_silently()); assert!(signal.cancellation().is_cancelled()); assert_eq!(attempt.resolve_failure(None), None); } #[test] fn owner_drop_clears_activity_and_makes_stale_signals_inert() { let (attempt, mut rx) = attempt(); let signal = attempt.signal(); let reporter = attempt.reporter(); assert!(attempt.set_activity(DownloadVerificationActivity::VerifyingDownloadedChunks)); drop(attempt); assert!(!signal.cancel_sources_exhausted()); assert!(!signal.cancel_silently()); assert!(!reporter.set_activity(DownloadVerificationActivity::RetryingInvalidSource)); assert!(matches!( rx.try_recv(), Ok(PeerEvent::DownloadGameFilesActivityChanged { activity: Some(DownloadVerificationActivity::VerifyingDownloadedChunks), .. }) )); assert!(matches!( rx.try_recv(), Ok(PeerEvent::DownloadGameFilesActivityChanged { activity: None, .. }) )); assert!(rx.try_recv().is_err()); } }