Replace address-only trust and pushed peer state with installation identities, SPKI-pinned QUIC, candidate-only discovery, and bounded responder-owned protocol-8 pulls. The runtime now owns each network generation and all admitted work through shutdown. Add exact bundled content identities, reproducible manifest publishing, capability-confined downloads, streaming BLAKE3 verification, quarantine and retry, and crash-recoverable download and install transactions. Ship generated fixture catalogs and fail closed when production manifests are absent. The Tauri backend exposes durable sharing policy, redacted identity state, and attempt-keyed transfer snapshots. Frontend consumption follows in the next commit. Repository-wide test certificates and protocol-7 paths are removed. BREAKING CHANGE: peers must use protocol 8 and exact catalog content artifacts; protocol-7 frames and shared-certificate identities are no longer accepted. Test Plan: - `just test` -- passed on the completed stack (708 workspace tests) - `just clippy` -- passed on the completed stack - `just build` -- passed with fixture catalogs on the completed stack - `just catalog-check-production` -- failed closed because the external production manifest corpus is absent - `git diff --cached --check` -- passed
602 lines
19 KiB
Rust
602 lines
19 KiB
Rust
//! 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
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<E>(self, value: &str) -> Result<Self::Value, E>
|
|
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::<u64>()
|
|
.map(DownloadAttemptId)
|
|
.map_err(|_| E::invalid_value(de::Unexpected::Str(value), &self))
|
|
}
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for DownloadAttemptId {
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
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<DownloadVerificationActivity>,
|
|
retry_activity_emitted: bool,
|
|
lifecycle: AttemptLifecycle,
|
|
terminal_emitted: bool,
|
|
}
|
|
|
|
struct DownloadAttemptState {
|
|
key: DownloadAttemptKey,
|
|
cancellation: CancellationToken,
|
|
tx_notify_ui: UnboundedSender<PeerEvent>,
|
|
runtime: Mutex<AttemptRuntimeState>,
|
|
}
|
|
|
|
/// Owner-side status handle retained for the complete structured operation.
|
|
pub(crate) struct DownloadAttemptStatus {
|
|
state: Arc<DownloadAttemptState>,
|
|
}
|
|
|
|
impl DownloadAttemptStatus {
|
|
pub(crate) fn new(
|
|
key: DownloadAttemptKey,
|
|
cancellation: CancellationToken,
|
|
tx_notify_ui: UnboundedSender<PeerEvent>,
|
|
) -> 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<DownloadFailureReason>,
|
|
) -> Option<DownloadFailureReason> {
|
|
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<DownloadFailureReason>) -> 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<DownloadAttemptState>,
|
|
}
|
|
|
|
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<DownloadAttemptState>,
|
|
}
|
|
|
|
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<PeerEvent>,
|
|
) {
|
|
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::<DownloadAttemptId>(&encoded)
|
|
.expect("decimal string should deserialize"),
|
|
first
|
|
);
|
|
assert!(serde_json::from_str::<DownloadAttemptId>("1").is_err());
|
|
assert!(serde_json::from_str::<DownloadAttemptId>("\"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());
|
|
}
|
|
}
|