feat(peer)!: cut over to authenticated catalog sharing
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
This commit is contained in:
128 files changed
+51759
-10784
No files matched your search
@@ -0,0 +1,601 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user