CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent), MCP server 接入外部 Agent 工具,GUI 开发中。
269 lines
9.6 KiB
Rust
269 lines
9.6 KiB
Rust
//! 健康度记忆的单测:冷却、恢复探测、排序、持久化 + 引擎驱动的落盘接线。
|
|
|
|
mod support;
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use unlucky_core::download::backoff::BackoffPolicy;
|
|
use unlucky_core::download::cache::{now_ms, ContentCache};
|
|
use unlucky_core::download::config::EngineConfig;
|
|
use unlucky_core::download::engine::DownloadEngine;
|
|
use unlucky_core::download::error::DownloadErrorKind;
|
|
use unlucky_core::download::health::{
|
|
HealthRegistry, SourceHealth, COOLDOWN_MS, FAILURE_THRESHOLD,
|
|
};
|
|
use unlucky_core::download::mirrors::MirrorTable;
|
|
use unlucky_core::download::region::RegionProfile;
|
|
use unlucky_core::download::types::{Candidate, DownloadRequest, ResourceKind, SOURCE_OFFICIAL};
|
|
|
|
use support::{MockResponse, MockServer};
|
|
|
|
const NOW: u64 = 1_000_000;
|
|
|
|
fn registry() -> HealthRegistry {
|
|
HealthRegistry::in_memory()
|
|
}
|
|
|
|
#[test]
|
|
fn fresh_source_is_neutral_not_penalized() {
|
|
let health = SourceHealth::default();
|
|
assert!(!health.is_cooling_down(NOW));
|
|
assert_eq!(health.failure_rate(), 0.0);
|
|
// 未知源必须是中间分,不能比刚成功的源更优先。
|
|
let healthy = SourceHealth {
|
|
samples: 3,
|
|
successes: 3,
|
|
ema_rtt_ms: 120.0,
|
|
..SourceHealth::default()
|
|
};
|
|
assert!(healthy.score(NOW) < health.score(NOW));
|
|
}
|
|
|
|
#[test]
|
|
fn three_consecutive_failures_trigger_cooldown() {
|
|
let mut health = SourceHealth::default();
|
|
for _ in 0..FAILURE_THRESHOLD {
|
|
health.record_failure("HTTP 502", NOW);
|
|
}
|
|
assert!(health.is_cooling_down(NOW));
|
|
assert!(health.is_cooling_down(NOW + COOLDOWN_MS - 1));
|
|
// 冷却到期后不再是冷却态,而是「可以探测」。
|
|
assert!(!health.is_cooling_down(NOW + COOLDOWN_MS));
|
|
assert!(health.is_probe(NOW + COOLDOWN_MS));
|
|
// 冷却中的源排序惩罚要明显高于健康源。
|
|
assert!(health.score(NOW) >= 1_000.0);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_artifact_is_recorded_without_cooling_down() {
|
|
// 院校镜像只留最新补丁:请求旧补丁必然 404,不能把源判成「坏」。
|
|
let mut health = SourceHealth::default();
|
|
for _ in 0..(FAILURE_THRESHOLD * 2) {
|
|
health.record_missing("HTTP 404", NOW);
|
|
}
|
|
assert!(!health.is_cooling_down(NOW), "4xx 不进冷却");
|
|
assert!(!health.is_probe(NOW), "4xx 不该产生恢复探测状态");
|
|
assert_eq!(health.failures, 0, "4xx 不计失败次数");
|
|
assert_eq!(health.not_found, u64::from(FAILURE_THRESHOLD * 2));
|
|
assert_eq!(health.failure_rate(), 0.0);
|
|
assert!(health.last_error.is_some(), "诊断仍要留痕");
|
|
// 打分不应被 404 拉高(仍是未知源的中位分)。
|
|
assert_eq!(health.score(NOW), SourceHealth::default().score(NOW));
|
|
assert_eq!(
|
|
health.score(NOW),
|
|
unlucky_core::download::health::SCORE_UNKNOWN
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn not_found_ageing_does_not_persist_a_cooldown() {
|
|
let registry = HealthRegistry::in_memory();
|
|
for _ in 0..10 {
|
|
registry.record_missing("tuna", ResourceKind::JavaRuntime, "HTTP 404", NOW);
|
|
}
|
|
let health = registry.health_of("tuna", ResourceKind::JavaRuntime);
|
|
assert!(!health.is_cooling_down(NOW));
|
|
assert_eq!(health.not_found, 10);
|
|
assert_eq!(health.consecutive_failures, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn probe_failure_re_arms_cooldown_and_success_clears_it() {
|
|
let mut health = SourceHealth::default();
|
|
for _ in 0..FAILURE_THRESHOLD {
|
|
health.record_failure("timeout", NOW);
|
|
}
|
|
let after_cooldown = NOW + COOLDOWN_MS + 1;
|
|
assert!(!health.is_cooling_down(after_cooldown));
|
|
// 探测再一次失败 → 立即重新冷却。
|
|
health.record_failure("timeout again", after_cooldown);
|
|
assert!(health.is_cooling_down(after_cooldown));
|
|
|
|
// 成功则全部清零。
|
|
health.record_success(90, after_cooldown + 1);
|
|
assert!(!health.is_cooling_down(after_cooldown + 1));
|
|
assert!(!health.is_probe(after_cooldown + 1));
|
|
assert_eq!(health.consecutive_failures, 0);
|
|
assert_eq!(health.cooldown_until_ms, 0);
|
|
assert_eq!(health.last_error, None);
|
|
}
|
|
|
|
#[test]
|
|
fn ema_smooths_latency_and_counts_samples() {
|
|
let mut health = SourceHealth::default();
|
|
health.record_success(100, NOW);
|
|
assert_eq!(health.ema_rtt_ms, 100.0);
|
|
health.record_success(200, NOW);
|
|
// 0.3 * 200 + 0.7 * 100 = 130
|
|
assert!((health.ema_rtt_ms - 130.0).abs() < 1e-9);
|
|
assert_eq!(health.samples, 2);
|
|
assert_eq!(health.successes, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn failure_rate_counts_both_sides() {
|
|
let mut health = SourceHealth::default();
|
|
health.record_success(50, NOW);
|
|
health.record_failure("boom", NOW);
|
|
assert!((health.failure_rate() - 0.5).abs() < 1e-9);
|
|
}
|
|
|
|
#[test]
|
|
fn registry_ranks_healthy_first_and_cooling_last() {
|
|
let registry = registry();
|
|
for _ in 0..FAILURE_THRESHOLD {
|
|
registry.record_failure("mirror-b", ResourceKind::ClientJar, "HTTP 500", NOW);
|
|
}
|
|
registry.record_success(
|
|
"mirror-a",
|
|
ResourceKind::ClientJar,
|
|
Duration::from_millis(80),
|
|
NOW,
|
|
);
|
|
|
|
let candidates = vec![
|
|
Candidate::new("mirror-b", "https://b.example/file"),
|
|
Candidate::new("unknown", "https://u.example/file"),
|
|
Candidate::new("mirror-a", "https://a.example/file"),
|
|
];
|
|
let ranked = registry.rank(&candidates, ResourceKind::ClientJar, NOW);
|
|
assert_eq!(ranked[0].source, "mirror-a");
|
|
assert_eq!(ranked[1].source, "unknown");
|
|
assert_eq!(
|
|
ranked[2].source, "mirror-b",
|
|
"冷却中的源排最后但仍保留为兜底"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn health_is_tracked_per_resource_kind() {
|
|
let registry = registry();
|
|
for _ in 0..FAILURE_THRESHOLD {
|
|
registry.record_failure("bmclapi", ResourceKind::Asset, "404", NOW);
|
|
}
|
|
assert!(registry.is_cooling_down("bmclapi", ResourceKind::Asset, NOW));
|
|
// 另一种资源类型不受影响(资产挂了不代表 JDK 也挂)。
|
|
assert!(!registry.is_cooling_down("bmclapi", ResourceKind::JavaRuntime, NOW));
|
|
}
|
|
|
|
#[test]
|
|
fn persistence_roundtrip_and_age_pruning() {
|
|
let dir = std::env::temp_dir().join(format!("ull-health-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("health.json");
|
|
|
|
let registry = HealthRegistry::open(&path, 7 * 24 * 3600 * 1000, NOW);
|
|
registry.record_failure("bmclapi", ResourceKind::Library, "HTTP 503", NOW);
|
|
registry.record_success(
|
|
"official",
|
|
ResourceKind::Library,
|
|
Duration::from_millis(55),
|
|
NOW,
|
|
);
|
|
registry.save().expect("持久化失败");
|
|
|
|
let reloaded = HealthRegistry::open(&path, 7 * 24 * 3600 * 1000, NOW + 1_000);
|
|
assert_eq!(reloaded.snapshot().len(), 2);
|
|
assert_eq!(
|
|
reloaded
|
|
.health_of("bmclapi", ResourceKind::Library)
|
|
.failures,
|
|
1
|
|
);
|
|
assert!(
|
|
reloaded
|
|
.health_of("official", ResourceKind::Library)
|
|
.ema_rtt_ms
|
|
> 0.0
|
|
);
|
|
|
|
// 超出最大保留期 → 丢弃旧记录(缓存目录被长时间放着的情况)。
|
|
let pruned = HealthRegistry::open(&path, 10, NOW + 60_000);
|
|
assert!(pruned.snapshot().is_empty());
|
|
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
/// P1 回归:健康度必须由**引擎的生产路径**落盘——下载→(模拟进程退出)→重开 registry,
|
|
/// 冷却状态与失败计数都要还在,否则「连续 3 败冷却 10 分钟」每轮启动从零学起。
|
|
#[tokio::test]
|
|
async fn engine_persists_health_so_a_restart_keeps_the_cooldown() {
|
|
let failed = || MockResponse::status(500);
|
|
let server = MockServer::start(vec![failed(), failed(), failed(), failed()]).await;
|
|
let url = server.url("/lib.jar");
|
|
let dir = support::temp_dir("health-engine-persist");
|
|
let path = dir.join("cache/health.json");
|
|
let now = now_ms();
|
|
let max_age_ms = 7 * 24 * 3600 * 1000;
|
|
|
|
let registry = Arc::new(HealthRegistry::open(&path, max_age_ms, now));
|
|
let engine = DownloadEngine::new(
|
|
EngineConfig {
|
|
max_attempts_per_source: FAILURE_THRESHOLD,
|
|
backoff: BackoffPolicy {
|
|
base_ms: 1,
|
|
max_ms: 2,
|
|
jitter_ratio: 0.0,
|
|
max_attempts: FAILURE_THRESHOLD,
|
|
},
|
|
..EngineConfig::default()
|
|
},
|
|
Arc::new(MirrorTable::builtin()),
|
|
Arc::new(RegionProfile::direct(0)),
|
|
Arc::clone(®istry),
|
|
Arc::new(ContentCache::open(dir.join("cache"))),
|
|
);
|
|
|
|
let error = engine
|
|
.download(DownloadRequest::new(
|
|
ResourceKind::Library,
|
|
url.as_str(),
|
|
dir.join("lib.jar"),
|
|
))
|
|
.await
|
|
.expect_err("全 500 必须失败");
|
|
assert_eq!(error.kind, DownloadErrorKind::AllSourcesFailed);
|
|
server.stop();
|
|
|
|
// 第 3 次连续失败进入冷却时就该落盘(不等收尾 flush)。
|
|
assert!(path.is_file(), "健康度必须已经落盘到 cache/health.json");
|
|
let before = registry.health_of(SOURCE_OFFICIAL, ResourceKind::Library);
|
|
assert!(before.consecutive_failures >= FAILURE_THRESHOLD);
|
|
assert!(before.is_cooling_down(now_ms()));
|
|
|
|
// 「进程退出后重开」:从同一个文件恢复,冷却状态与失败计数都还在。
|
|
let reopened = HealthRegistry::open(&path, max_age_ms, now_ms());
|
|
let restored = reopened.health_of(SOURCE_OFFICIAL, ResourceKind::Library);
|
|
assert_eq!(restored.consecutive_failures, before.consecutive_failures);
|
|
assert_eq!(restored.failures, before.failures);
|
|
assert!(
|
|
restored.is_cooling_down(now_ms()),
|
|
"重开后仍应处于冷却:{restored:?}"
|
|
);
|
|
assert!(restored.last_error.is_some(), "失败原因也要持久化");
|
|
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|