Unlucky Launcher v0.2.0
CI / runtime smoke (macos arm64, probe) (push) Skipped
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
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
CI / runtime smoke (macos arm64, probe) (push) Skipped
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
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
首个 iced + Material 3 Expressive 版本的源码快照。 完整历史与日常提交在 dev 分支;main 只放阶段性快照。
This commit is contained in:
commit
8a8e5ca0c0
1079 files changed
+208035
No files matched your search
@@ -0,0 +1,68 @@
|
||||
//! 指数退避与抖动的单测(纯函数,无网络)。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use unlucky_core::download::backoff::{BackoffPolicy, Jitter};
|
||||
|
||||
#[test]
|
||||
fn exponential_growth_doubles_and_caps() {
|
||||
let policy = BackoffPolicy::default();
|
||||
assert_eq!(policy.delay(0), Duration::from_millis(1_000));
|
||||
assert_eq!(policy.delay(1), Duration::from_millis(2_000));
|
||||
assert_eq!(policy.delay(2), Duration::from_millis(4_000));
|
||||
assert_eq!(policy.delay(3), Duration::from_millis(8_000));
|
||||
// 上限封顶,不会涨到分钟级。
|
||||
assert_eq!(policy.delay(10), Duration::from_millis(30_000));
|
||||
assert_eq!(policy.delay(u32::MAX), Duration::from_millis(30_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_retry_policy_waits_nothing() {
|
||||
let policy = BackoffPolicy::none();
|
||||
assert_eq!(policy.delay(0), Duration::ZERO);
|
||||
assert_eq!(policy.max_attempts, 1);
|
||||
assert_eq!(policy.delay_jittered(0, 0.5), Duration::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jitter_stays_within_ratio_bounds() {
|
||||
let policy = BackoffPolicy::default();
|
||||
// base 1000ms ± 30% → [700, 1300]
|
||||
let low = policy.delay_jittered(0, 0.0);
|
||||
let high = policy.delay_jittered(0, 1.0);
|
||||
assert_eq!(low, Duration::from_millis(700));
|
||||
assert_eq!(high, Duration::from_millis(1_300));
|
||||
// 抖动永不超过 max_ms。
|
||||
for rng in [0.0, 0.25, 0.5, 0.75, 1.0] {
|
||||
assert!(policy.delay_jittered(9, rng) <= Duration::from_millis(30_000));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jitter_without_ratio_is_deterministic() {
|
||||
let policy = BackoffPolicy {
|
||||
jitter_ratio: 0.0,
|
||||
..BackoffPolicy::default()
|
||||
};
|
||||
assert_eq!(
|
||||
policy.delay_jittered(2, 0.0),
|
||||
policy.delay_jittered(2, 0.99)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jitter_source_is_reproducible_and_in_range() {
|
||||
let mut first = Jitter::new(42);
|
||||
let mut second = Jitter::new(42);
|
||||
for _ in 0..32 {
|
||||
let a = first.next_fraction();
|
||||
let b = second.next_fraction();
|
||||
assert_eq!(a, b, "同种子的抖动序列必须一致");
|
||||
assert!((0.0..1.0).contains(&a));
|
||||
}
|
||||
|
||||
let mut other = Jitter::new(7);
|
||||
assert_ne!(Jitter::new(1).next_u64(), other.next_u64());
|
||||
// 种子 0 会被替换成固定非零种子,但仍然可用。
|
||||
assert!((0.0..1.0).contains(&Jitter::new(0).next_fraction()));
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! 内容寻址缓存的单测:两级分片、记录信任(命中不重算)、损坏检测、索引持久化。
|
||||
|
||||
mod support;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use unlucky_core::download::cache::ContentCache;
|
||||
use unlucky_core::download::types::{HashAlgo, Integrity};
|
||||
|
||||
const NOW: u64 = 5_000_000;
|
||||
|
||||
fn cache_dir(tag: &str) -> std::path::PathBuf {
|
||||
support::temp_dir(tag)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharded_path_follows_layout() {
|
||||
let dir = cache_dir("cache-path");
|
||||
let cache = ContentCache::open(&dir);
|
||||
let integrity = Integrity::sha1("abcdef0123456789abcdef0123456789abcdef01");
|
||||
let path = cache.path_for(integrity.algo, &integrity.hex);
|
||||
assert!(path.ends_with("sha1/ab/abcdef0123456789abcdef0123456789abcdef01"));
|
||||
assert!(path.starts_with(cache.root()));
|
||||
// SHA-256 单独一棵子树。
|
||||
let sha256_path = cache.path_for(HashAlgo::Sha256, "00112233445566778899aabbccddeeff");
|
||||
assert!(sha256_path.ends_with("sha256/00/00112233445566778899aabbccddeeff"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_then_lookup_hits_without_rehashing() {
|
||||
let dir = cache_dir("cache-hit");
|
||||
let cache = ContentCache::open(&dir);
|
||||
|
||||
let payload = b"hello unlucky launcher".to_vec();
|
||||
let integrity = Integrity::sha1(support::sha1_hex(&payload));
|
||||
let source = dir.join("download.bin");
|
||||
std::fs::write(&source, &payload).unwrap();
|
||||
|
||||
let cached = cache
|
||||
.insert(&source, &integrity, NOW)
|
||||
.expect("写入缓存失败");
|
||||
assert!(cached.is_file());
|
||||
assert_eq!(cache.len(), 1);
|
||||
|
||||
// 第二次命中:size/mtime 未变 → 记录信任,直接返回(不重算摘要)。
|
||||
let hit = cache.lookup(&integrity, NOW + 10).expect("应命中缓存");
|
||||
assert_eq!(hit, cached);
|
||||
|
||||
// 文件被删 → 记录作废。
|
||||
std::fs::remove_file(&cached).unwrap();
|
||||
assert!(cache.lookup(&integrity, NOW + 20).is_none());
|
||||
assert_eq!(cache.len(), 0);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_cache_file_is_a_miss() {
|
||||
let dir = cache_dir("cache-truncate");
|
||||
let cache = ContentCache::open(&dir);
|
||||
|
||||
let payload = vec![7_u8; 256];
|
||||
let integrity = Integrity::sha1(support::sha1_hex(&payload));
|
||||
let source = dir.join("payload.bin");
|
||||
std::fs::write(&source, &payload).unwrap();
|
||||
let cached = cache.insert(&source, &integrity, NOW).unwrap();
|
||||
|
||||
// 半损坏(长度变了)必须立刻视为未命中。
|
||||
std::fs::write(&cached, &payload[..128]).unwrap();
|
||||
assert!(cache.lookup(&integrity, NOW + 1).is_none());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_change_forces_a_rehash() {
|
||||
let dir = cache_dir("cache-rehash");
|
||||
let cache = ContentCache::open(&dir);
|
||||
|
||||
let payload = vec![3_u8; 128];
|
||||
let integrity = Integrity::sha1(support::sha1_hex(&payload));
|
||||
let source = dir.join("data.bin");
|
||||
std::fs::write(&source, &payload).unwrap();
|
||||
let cached = cache.insert(&source, &integrity, NOW).unwrap();
|
||||
// 硬链接/复制都可能让源文件与缓存同 inode,先断开再改缓存内容。
|
||||
let _ = std::fs::remove_file(&source);
|
||||
|
||||
// 等 mtime 变化(部分文件系统时间戳精度 10ms)。
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
let mut tampered = payload.clone();
|
||||
tampered[0] = 9;
|
||||
std::fs::write(&cached, &tampered).unwrap();
|
||||
|
||||
// 内容变了 → 摘要不符 → 记录作废。
|
||||
assert!(cache.lookup(&integrity, NOW + 100).is_none());
|
||||
assert_eq!(cache.len(), 0);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_verified_rejects_wrong_content_with_same_size() {
|
||||
let dir = cache_dir("cache-verified");
|
||||
let cache = ContentCache::open(&dir);
|
||||
|
||||
let payload = vec![1_u8; 64];
|
||||
let integrity = Integrity::sha1(support::sha1_hex(&payload));
|
||||
// 直接放到缓存位但内容是错的(模拟外部篡改 / 镜像写坏)。
|
||||
let path = cache.path_for(integrity.algo, &integrity.hex);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, vec![2_u8; 64]).unwrap();
|
||||
cache.register(&path, &integrity, NOW).unwrap();
|
||||
|
||||
assert!(
|
||||
cache.lookup_verified(&integrity, NOW + 1).is_none(),
|
||||
"强制校验必须发现内容不符"
|
||||
);
|
||||
|
||||
// 内容对了就能通过。
|
||||
std::fs::write(&path, &payload).unwrap();
|
||||
cache.register(&path, &integrity, NOW).unwrap();
|
||||
assert!(cache.lookup_verified(&integrity, NOW + 2).is_some());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_persists_across_reopen() {
|
||||
let dir = cache_dir("cache-persist");
|
||||
let payload = vec![5_u8; 32];
|
||||
let integrity = Integrity::sha1(support::sha1_hex(&payload));
|
||||
|
||||
{
|
||||
let cache = ContentCache::open(&dir);
|
||||
let source = dir.join("src.bin");
|
||||
std::fs::write(&source, &payload).unwrap();
|
||||
cache.insert(&source, &integrity, NOW).unwrap();
|
||||
cache.persist().expect("持久化索引失败");
|
||||
assert_eq!(cache.stats().entries, 1);
|
||||
assert_eq!(cache.stats().total_bytes, 32);
|
||||
}
|
||||
|
||||
let reopened = ContentCache::open(&dir);
|
||||
assert_eq!(reopened.len(), 1);
|
||||
assert!(reopened.lookup(&integrity, NOW + 1).is_some());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
//! 分块规划与 Range 语义的单测(纯函数)+ 真跑 4 连接的端到端用例(mock 源)。
|
||||
|
||||
mod support;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use unlucky_core::download::backoff::BackoffPolicy;
|
||||
use unlucky_core::download::cache::ContentCache;
|
||||
use unlucky_core::download::chunked::{
|
||||
content_range_matches, parse_content_range, plan, range_support, ByteRange, ChunkedConfig,
|
||||
ContentRange, RangeSupport, MAX_CONNECTIONS,
|
||||
};
|
||||
use unlucky_core::download::config::EngineConfig;
|
||||
use unlucky_core::download::engine::DownloadEngine;
|
||||
use unlucky_core::download::health::HealthRegistry;
|
||||
use unlucky_core::download::mirrors::MirrorTable;
|
||||
use unlucky_core::download::region::RegionProfile;
|
||||
use unlucky_core::download::types::{DownloadRequest, ResourceKind};
|
||||
|
||||
use support::{sha1_hex, temp_dir, MockResponse, MockServer};
|
||||
|
||||
#[test]
|
||||
fn plan_partitions_whole_file_without_gaps() {
|
||||
let cases = [
|
||||
(1_u64, 4_usize),
|
||||
(3, 4),
|
||||
(100, 4),
|
||||
(1000, 7),
|
||||
(123_456_789, 4),
|
||||
];
|
||||
for (total, connections) in cases {
|
||||
let ranges = plan(total, connections);
|
||||
assert!(!ranges.is_empty(), "total={total} 不能切出空集");
|
||||
assert_eq!(ranges[0].start, 0, "必须从 0 开始");
|
||||
assert_eq!(ranges.last().unwrap().end, total - 1, "必须覆盖到末尾");
|
||||
let mut next = 0_u64;
|
||||
for range in &ranges {
|
||||
assert_eq!(range.start, next, "段之间不能有空洞或重叠");
|
||||
assert!(!range.is_empty());
|
||||
next = range.end + 1;
|
||||
}
|
||||
assert_eq!(next, total);
|
||||
assert_eq!(ranges.iter().map(ByteRange::len).sum::<u64>(), total);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_caps_connections_and_handles_edge_cases() {
|
||||
assert!(plan(0, 4).is_empty());
|
||||
// 文件比连接数还小:每段 1 字节。
|
||||
let tiny = plan(3, 4);
|
||||
assert_eq!(tiny.len(), 3);
|
||||
assert!(tiny.iter().all(|range| range.len() == 1));
|
||||
// 连接数被夹到上限。
|
||||
let many = plan(1_000_000, 100);
|
||||
assert_eq!(many.len(), MAX_CONNECTIONS);
|
||||
// 单连接。
|
||||
assert_eq!(plan(120, 1), vec![ByteRange::new(0, 119)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_range_headers_are_closed_intervals() {
|
||||
let range = ByteRange::new(100, 199);
|
||||
assert_eq!(range.len(), 100);
|
||||
assert_eq!(range.header_value(), "bytes=100-199");
|
||||
assert!(ByteRange::new(10, 9).is_empty());
|
||||
assert_eq!(ByteRange::new(10, 9).len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_content_range_accepts_servers_forms() {
|
||||
assert_eq!(
|
||||
parse_content_range("bytes 0-99/1000"),
|
||||
Some(ContentRange {
|
||||
start: 0,
|
||||
end: 99,
|
||||
total: Some(1000)
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse_content_range(" bytes 100-199/* "),
|
||||
Some(ContentRange {
|
||||
start: 100,
|
||||
end: 199,
|
||||
total: None
|
||||
})
|
||||
);
|
||||
// 畸形一律 None:调用方据此退化。
|
||||
assert_eq!(parse_content_range("bytes 100-99/1000"), None);
|
||||
assert_eq!(parse_content_range("items 0-9/10"), None);
|
||||
assert_eq!(parse_content_range("bytes 0-9"), None);
|
||||
assert_eq!(parse_content_range(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_support_matrix() {
|
||||
assert_eq!(range_support(Some("bytes"), 200), RangeSupport::Supported);
|
||||
assert_eq!(range_support(None, 206), RangeSupport::Supported);
|
||||
assert_eq!(range_support(Some("none"), 200), RangeSupport::Unsupported);
|
||||
assert_eq!(
|
||||
range_support(Some("crumbs"), 200),
|
||||
RangeSupport::Unsupported
|
||||
);
|
||||
assert_eq!(range_support(None, 200), RangeSupport::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunked_config_gate() {
|
||||
let config = ChunkedConfig::default();
|
||||
assert!(config.enabled);
|
||||
assert_eq!(config.connections, 4);
|
||||
// 只有大文件 + 大资源类型才分块。
|
||||
assert!(config.applies_to(ResourceKind::JavaRuntime, Some(200 * 1024 * 1024)));
|
||||
assert!(!config.applies_to(ResourceKind::JavaRuntime, Some(1024)));
|
||||
assert!(!config.applies_to(ResourceKind::ClientJar, Some(200 * 1024 * 1024)));
|
||||
assert!(!config.applies_to(ResourceKind::JavaRuntime, None));
|
||||
// 设置项关掉后一律不分块。
|
||||
assert!(!ChunkedConfig::disabled().applies_to(ResourceKind::JavaRuntime, Some(u64::MAX)));
|
||||
assert_eq!(
|
||||
ChunkedConfig {
|
||||
connections: 999,
|
||||
..ChunkedConfig::default()
|
||||
}
|
||||
.effective_connections(),
|
||||
MAX_CONNECTIONS
|
||||
);
|
||||
}
|
||||
|
||||
/// 端到端:`min_size` 调到 1KiB 后,20KiB 的 JDK 类资源真的被切成 4 段并行取。
|
||||
///
|
||||
/// 断言三件事:①mock 源收到 4 次带 `Range` 的请求(不是 1 次)②拼装后的文件逐字节正确
|
||||
/// ③流式 sha1 校验通过(证明分块乱序写没有错位)。
|
||||
#[tokio::test]
|
||||
async fn downloads_large_file_over_four_parallel_ranges() {
|
||||
let payload: Vec<u8> = (0..20 * 1024).map(|index| (index % 251) as u8).collect();
|
||||
let server = MockServer::start(vec![
|
||||
MockResponse::ok(payload.clone()),
|
||||
MockResponse::ok(payload.clone()),
|
||||
MockResponse::ok(payload.clone()),
|
||||
MockResponse::ok(payload.clone()),
|
||||
])
|
||||
.await;
|
||||
let url = server.url("/jdk.zip");
|
||||
let dir = temp_dir("chunked-e2e");
|
||||
let engine = DownloadEngine::new(
|
||||
EngineConfig {
|
||||
max_attempts_per_source: 1,
|
||||
backoff: BackoffPolicy {
|
||||
base_ms: 1,
|
||||
max_ms: 2,
|
||||
jitter_ratio: 0.0,
|
||||
max_attempts: 1,
|
||||
},
|
||||
read_timeout: Duration::from_secs(5),
|
||||
connect_timeout: Duration::from_secs(5),
|
||||
// 真开分块:4 条连接,1KiB 以上就切。
|
||||
chunked: ChunkedConfig {
|
||||
enabled: true,
|
||||
connections: 4,
|
||||
min_size: 1024,
|
||||
},
|
||||
..EngineConfig::default()
|
||||
},
|
||||
Arc::new(MirrorTable::builtin()),
|
||||
Arc::new(RegionProfile::direct(0)),
|
||||
Arc::new(HealthRegistry::in_memory()),
|
||||
Arc::new(ContentCache::open(dir.join("cache"))),
|
||||
);
|
||||
|
||||
let dest = dir.join("jdk.zip");
|
||||
let outcome = engine
|
||||
.download(
|
||||
DownloadRequest::new(ResourceKind::JavaRuntime, url.as_str(), &dest)
|
||||
.with_sha1(sha1_hex(&payload))
|
||||
.with_size(payload.len() as u64),
|
||||
)
|
||||
.await
|
||||
.expect("分块下载失败");
|
||||
|
||||
assert_eq!(outcome.bytes, payload.len() as u64);
|
||||
assert_eq!(outcome.source, "official");
|
||||
assert_eq!(
|
||||
server.hits(),
|
||||
4,
|
||||
"4 条连接应各发一次带 Range 的请求(退化单连接会只有 1 次)"
|
||||
);
|
||||
assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload);
|
||||
assert!(
|
||||
support::part_files(&dir).is_empty(),
|
||||
"中间文件必须在提交后消失:{:?}",
|
||||
support::part_files(&dir)
|
||||
);
|
||||
server.stop();
|
||||
}
|
||||
|
||||
/// 端到端:目标目录**还不存在**时分块下载也要能落盘。
|
||||
///
|
||||
/// 回归用例。分块路径先 `OpenOptions` 预分配整文件(不走 `PartFile::open_append`
|
||||
/// 那层建目录),所以「下到新建目录」曾让所有候选源瞬间全部失败
|
||||
/// (现场:`data/java-managed/.cache/` 未创建时装 JDK 六个源全挂)。
|
||||
#[tokio::test]
|
||||
async fn chunked_download_creates_the_destination_directory() {
|
||||
let payload: Vec<u8> = (0..8 * 1024).map(|index| (index % 31) as u8).collect();
|
||||
let responses = vec![
|
||||
MockResponse::ok(payload.clone()),
|
||||
MockResponse::ok(payload.clone()),
|
||||
MockResponse::ok(payload.clone()),
|
||||
MockResponse::ok(payload.clone()),
|
||||
];
|
||||
let server = MockServer::start(responses).await;
|
||||
let url = server.url("/jdk.zip");
|
||||
let dir = temp_dir("chunked-mkdir");
|
||||
let engine = DownloadEngine::new(
|
||||
EngineConfig {
|
||||
max_attempts_per_source: 1,
|
||||
backoff: BackoffPolicy {
|
||||
base_ms: 1,
|
||||
max_ms: 2,
|
||||
jitter_ratio: 0.0,
|
||||
max_attempts: 1,
|
||||
},
|
||||
read_timeout: Duration::from_secs(5),
|
||||
connect_timeout: Duration::from_secs(5),
|
||||
chunked: ChunkedConfig {
|
||||
enabled: true,
|
||||
connections: 4,
|
||||
min_size: 1024,
|
||||
},
|
||||
..EngineConfig::default()
|
||||
},
|
||||
Arc::new(MirrorTable::builtin()),
|
||||
Arc::new(RegionProfile::direct(0)),
|
||||
Arc::new(HealthRegistry::in_memory()),
|
||||
Arc::new(ContentCache::open(dir.join("cache"))),
|
||||
);
|
||||
|
||||
let dest = dir.join("java-managed/.cache/jdk.zip");
|
||||
assert!(
|
||||
!dest.parent().expect("父目录").exists(),
|
||||
"前置条件:目录不存在"
|
||||
);
|
||||
let outcome = engine
|
||||
.download(
|
||||
DownloadRequest::new(ResourceKind::JavaRuntime, url.as_str(), &dest)
|
||||
.with_sha1(sha1_hex(&payload))
|
||||
.with_size(payload.len() as u64),
|
||||
)
|
||||
.await
|
||||
.expect("分块下载应成功");
|
||||
assert_eq!(outcome.bytes, payload.len() as u64);
|
||||
assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload);
|
||||
server.stop();
|
||||
}
|
||||
|
||||
/// 端到端:服务器声明支持 Range 但实际无视(返回 200)→ 自动退化单连接,结果仍然正确。
|
||||
#[tokio::test]
|
||||
async fn falls_back_to_single_connection_when_server_ignores_range() {
|
||||
let payload: Vec<u8> = (0..8 * 1024).map(|index| (index % 97) as u8).collect();
|
||||
// 每个请求都会拿到 200 + 整份内容(range_support = false)。
|
||||
// 响应条数给足:4 个分块请求可能都发出去才被 abort,退化后的整份请求是第 5 条。
|
||||
let no_range = || MockResponse::ok(payload.clone()).without_range();
|
||||
let server = MockServer::start(vec![
|
||||
no_range(),
|
||||
no_range(),
|
||||
no_range(),
|
||||
no_range(),
|
||||
no_range(),
|
||||
no_range(),
|
||||
])
|
||||
.await;
|
||||
let url = server.url("/client.jar");
|
||||
let dir = temp_dir("chunked-fallback");
|
||||
let engine = DownloadEngine::new(
|
||||
EngineConfig {
|
||||
max_attempts_per_source: 1,
|
||||
backoff: BackoffPolicy::none(),
|
||||
chunked: ChunkedConfig {
|
||||
enabled: true,
|
||||
connections: 4,
|
||||
min_size: 1024,
|
||||
},
|
||||
..EngineConfig::default()
|
||||
},
|
||||
Arc::new(MirrorTable::builtin()),
|
||||
Arc::new(RegionProfile::direct(0)),
|
||||
Arc::new(HealthRegistry::in_memory()),
|
||||
Arc::new(ContentCache::open(dir.join("cache"))),
|
||||
);
|
||||
|
||||
let dest = dir.join("client.jar");
|
||||
let outcome = engine
|
||||
.download(
|
||||
DownloadRequest::new(ResourceKind::JavaRuntime, url.as_str(), &dest)
|
||||
.with_sha1(sha1_hex(&payload))
|
||||
.with_size(payload.len() as u64),
|
||||
)
|
||||
.await
|
||||
.expect("退化后应仍能下载成功");
|
||||
|
||||
assert_eq!(outcome.bytes, payload.len() as u64);
|
||||
assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload);
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_range_must_match_requested_segment() {
|
||||
let requested = ByteRange::new(5120, 10239);
|
||||
// 严格一致才放行。
|
||||
assert!(content_range_matches(
|
||||
requested,
|
||||
Some("bytes 5120-10239/20480")
|
||||
));
|
||||
// 起止任一不符 → 判失败(否则「长度恰好但位置错」的响应会拼出坏文件)。
|
||||
assert!(!content_range_matches(
|
||||
requested,
|
||||
Some("bytes 0-5119/20480")
|
||||
));
|
||||
assert!(!content_range_matches(
|
||||
requested,
|
||||
Some("bytes 5120-20479/20480")
|
||||
));
|
||||
// 畸形头判失败。
|
||||
assert!(!content_range_matches(requested, Some("garbage")));
|
||||
// 缺头从宽(长度校验仍兜底)。
|
||||
assert!(content_range_matches(requested, None));
|
||||
}
|
||||
|
||||
/// 端到端:服务器回 206 但 `Content-Range` 谎报段位置 → 分块路径判不符、丢弃中间文件、
|
||||
/// 退化单连接重下,最终文件仍逐字节正确(不会交付拼错的文件)。
|
||||
#[tokio::test]
|
||||
async fn rejects_lying_content_range_and_falls_back() {
|
||||
let payload: Vec<u8> = (0..8 * 1024).map(|index| (index % 89) as u8).collect();
|
||||
let lying = || MockResponse::ok(payload.clone()).with_wrong_content_range();
|
||||
let server = MockServer::start(vec![
|
||||
lying(),
|
||||
lying(),
|
||||
lying(),
|
||||
lying(),
|
||||
MockResponse::ok(payload.clone()),
|
||||
])
|
||||
.await;
|
||||
let url = server.url("/jdk.zip");
|
||||
let dir = temp_dir("chunked-lying-range");
|
||||
let engine = DownloadEngine::new(
|
||||
EngineConfig {
|
||||
max_attempts_per_source: 1,
|
||||
backoff: BackoffPolicy::none(),
|
||||
chunked: ChunkedConfig {
|
||||
enabled: true,
|
||||
connections: 4,
|
||||
min_size: 1024,
|
||||
},
|
||||
..EngineConfig::default()
|
||||
},
|
||||
Arc::new(MirrorTable::builtin()),
|
||||
Arc::new(RegionProfile::direct(0)),
|
||||
Arc::new(HealthRegistry::in_memory()),
|
||||
Arc::new(ContentCache::open(dir.join("cache"))),
|
||||
);
|
||||
|
||||
let dest = dir.join("jdk.zip");
|
||||
let outcome = engine
|
||||
.download(
|
||||
DownloadRequest::new(ResourceKind::JavaRuntime, url.as_str(), &dest)
|
||||
.with_sha1(sha1_hex(&payload))
|
||||
.with_size(payload.len() as u64),
|
||||
)
|
||||
.await
|
||||
.expect("谎报 Content-Range 后应退化单连接成功");
|
||||
|
||||
assert_eq!(outcome.bytes, payload.len() as u64);
|
||||
assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload);
|
||||
server.stop();
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
//! 引擎端到端(mock 源):成功 / 退避重试 / 换源 / 校验失败 / 缓存命中 / 断点续传。
|
||||
//!
|
||||
//! 用 `tests/download/support` 里的进程内 HTTP 服务器,不发真实网络请求。
|
||||
|
||||
mod support;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use unlucky_core::download::backoff::BackoffPolicy;
|
||||
use unlucky_core::download::cache::ContentCache;
|
||||
use unlucky_core::download::chunked::ChunkedConfig;
|
||||
use unlucky_core::download::config::EngineConfig;
|
||||
use unlucky_core::download::engine::DownloadEngine;
|
||||
use unlucky_core::download::error::DownloadErrorKind;
|
||||
use unlucky_core::download::health::HealthRegistry;
|
||||
use unlucky_core::download::mirrors::{MirrorTable, PrefixRule};
|
||||
use unlucky_core::download::progress::{ProgressEmitter, ProgressEvent};
|
||||
use unlucky_core::download::region::RegionProfile;
|
||||
use unlucky_core::download::resume::ResumeMeta;
|
||||
use unlucky_core::download::types::{DownloadRequest, HashAlgo, ResourceKind};
|
||||
|
||||
use support::{sha1_hex, temp_dir, MockResponse, MockServer};
|
||||
|
||||
fn fast_config(attempts: u32) -> EngineConfig {
|
||||
EngineConfig {
|
||||
max_attempts_per_source: attempts,
|
||||
backoff: BackoffPolicy {
|
||||
base_ms: 1,
|
||||
max_ms: 2,
|
||||
jitter_ratio: 0.0,
|
||||
max_attempts: attempts,
|
||||
},
|
||||
connect_timeout: Duration::from_secs(5),
|
||||
read_timeout: Duration::from_secs(5),
|
||||
chunked: ChunkedConfig::disabled(),
|
||||
..EngineConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
struct Harness {
|
||||
engine: DownloadEngine,
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
fn harness(cfg: EngineConfig, table: MirrorTable, region: RegionProfile, tag: &str) -> Harness {
|
||||
let dir = temp_dir(tag);
|
||||
let cache = Arc::new(ContentCache::open(dir.join("cache")));
|
||||
let health = Arc::new(HealthRegistry::in_memory());
|
||||
let engine = DownloadEngine::new(cfg, Arc::new(table), Arc::new(region), health, cache);
|
||||
Harness { engine, dir }
|
||||
}
|
||||
|
||||
fn direct_region() -> RegionProfile {
|
||||
RegionProfile::direct(0)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn downloads_verifies_and_leaves_no_part_file() {
|
||||
let payload = b"unlucky launcher payload".to_vec();
|
||||
let server = MockServer::start(vec![MockResponse::ok(payload.clone())]).await;
|
||||
let url = server.url("/client.jar");
|
||||
let harness = harness(
|
||||
fast_config(2),
|
||||
MirrorTable::builtin(),
|
||||
direct_region(),
|
||||
"engine-ok",
|
||||
);
|
||||
let dest = harness.dir.join("versions/1.20.1/client.jar");
|
||||
|
||||
let request = DownloadRequest::new(ResourceKind::ClientJar, url.as_str(), &dest)
|
||||
.with_sha1(sha1_hex(&payload))
|
||||
.with_size(payload.len() as u64);
|
||||
let outcome = harness.engine.download(request).await.expect("下载应成功");
|
||||
|
||||
assert_eq!(outcome.bytes, payload.len() as u64);
|
||||
assert!(!outcome.from_cache);
|
||||
assert_eq!(outcome.resumed_from, 0);
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), payload);
|
||||
let version_dir = harness.dir.join("versions/1.20.1");
|
||||
assert!(
|
||||
support::part_files(&version_dir).is_empty(),
|
||||
"中间文件必须被提交掉:{:?}",
|
||||
support::part_files(&version_dir)
|
||||
);
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retries_same_source_after_server_error() {
|
||||
let payload = b"retry me".to_vec();
|
||||
let server = MockServer::start(vec![
|
||||
MockResponse::status(503),
|
||||
MockResponse::ok(payload.clone()),
|
||||
])
|
||||
.await;
|
||||
let url = server.url("/lib.jar");
|
||||
let harness = harness(
|
||||
fast_config(3),
|
||||
MirrorTable::builtin(),
|
||||
direct_region(),
|
||||
"engine-retry",
|
||||
);
|
||||
let dest = harness.dir.join("lib.jar");
|
||||
|
||||
let outcome = harness
|
||||
.engine
|
||||
.download(
|
||||
DownloadRequest::new(ResourceKind::Library, url.as_str(), &dest)
|
||||
.with_sha1(sha1_hex(&payload)),
|
||||
)
|
||||
.await
|
||||
.expect("退避后应成功");
|
||||
assert_eq!(outcome.bytes, payload.len() as u64);
|
||||
assert_eq!(server.hits(), 2, "第一次 503 之后应重试一次");
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn switches_to_official_when_mirror_keeps_failing() {
|
||||
let payload = b"official wins".to_vec();
|
||||
let official = MockServer::start(vec![MockResponse::ok(payload.clone())]).await;
|
||||
let mirror = MockServer::start(vec![
|
||||
MockResponse::status(500),
|
||||
MockResponse::status(500),
|
||||
MockResponse::status(500),
|
||||
])
|
||||
.await;
|
||||
|
||||
let mut table = MirrorTable::builtin();
|
||||
let official_prefix = format!("http://127.0.0.1:{}", official.port());
|
||||
let mirror_prefix = format!("http://localhost:{}", mirror.port());
|
||||
table.add_custom_rule(PrefixRule::new(
|
||||
"mock-mirror",
|
||||
official_prefix,
|
||||
mirror_prefix,
|
||||
));
|
||||
|
||||
let harness = harness(
|
||||
fast_config(2),
|
||||
table,
|
||||
RegionProfile::mirror_first(0),
|
||||
"engine-switch",
|
||||
);
|
||||
let dest = harness.dir.join("client.jar");
|
||||
let url = official.url("/client.jar");
|
||||
|
||||
let outcome = harness
|
||||
.engine
|
||||
.download(
|
||||
DownloadRequest::new(ResourceKind::ClientJar, url.as_str(), &dest)
|
||||
.with_sha1(sha1_hex(&payload)),
|
||||
)
|
||||
.await
|
||||
.expect("镜像全挂后应切到官方");
|
||||
assert_eq!(outcome.source, "official");
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), payload);
|
||||
assert_eq!(mirror.hits(), 2, "每个源最多尝试 max_attempts 次");
|
||||
official.stop();
|
||||
mirror.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_wrong_checksum_and_falls_back() {
|
||||
let payload = b"the real jar".to_vec();
|
||||
let official = MockServer::start(vec![MockResponse::ok(payload.clone())]).await;
|
||||
let mirror = MockServer::start(vec![MockResponse::ok(b"tampered by mirror".to_vec())]).await;
|
||||
|
||||
let mut table = MirrorTable::builtin();
|
||||
table.add_custom_rule(PrefixRule::new(
|
||||
"mock-mirror",
|
||||
format!("http://127.0.0.1:{}", official.port()),
|
||||
format!("http://localhost:{}", mirror.port()),
|
||||
));
|
||||
|
||||
let harness = harness(
|
||||
fast_config(1),
|
||||
table,
|
||||
RegionProfile::mirror_first(0),
|
||||
"engine-checksum",
|
||||
);
|
||||
let dest = harness.dir.join("client.jar");
|
||||
let url = official.url("/client.jar");
|
||||
|
||||
let outcome = harness
|
||||
.engine
|
||||
.download(
|
||||
DownloadRequest::new(ResourceKind::ClientJar, url.as_str(), &dest)
|
||||
.with_sha1(sha1_hex(&payload))
|
||||
.with_size(payload.len() as u64),
|
||||
)
|
||||
.await
|
||||
.expect("校验失败应换源而不是交付坏文件");
|
||||
assert_eq!(outcome.source, "official");
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), payload);
|
||||
assert!(mirror.hits() >= 1);
|
||||
official.stop();
|
||||
mirror.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn second_download_hits_content_cache_without_network() {
|
||||
let payload = b"cached payload".to_vec();
|
||||
let server = MockServer::start(vec![
|
||||
MockResponse::ok(payload.clone()),
|
||||
MockResponse::ok(payload.clone()),
|
||||
])
|
||||
.await;
|
||||
let url = server.url("/lib.jar");
|
||||
let harness = harness(
|
||||
fast_config(2),
|
||||
MirrorTable::builtin(),
|
||||
direct_region(),
|
||||
"engine-cache",
|
||||
);
|
||||
let integrity = sha1_hex(&payload);
|
||||
|
||||
let first = harness
|
||||
.engine
|
||||
.download(
|
||||
DownloadRequest::new(
|
||||
ResourceKind::Library,
|
||||
url.as_str(),
|
||||
harness.dir.join("a/lib.jar"),
|
||||
)
|
||||
.with_sha1(integrity.as_str()),
|
||||
)
|
||||
.await
|
||||
.expect("首次下载失败");
|
||||
assert!(!first.from_cache);
|
||||
|
||||
let hits_after_first = server.hits();
|
||||
let second = harness
|
||||
.engine
|
||||
.download(
|
||||
DownloadRequest::new(
|
||||
ResourceKind::Library,
|
||||
url.as_str(),
|
||||
harness.dir.join("b/lib.jar"),
|
||||
)
|
||||
.with_sha1(integrity.as_str()),
|
||||
)
|
||||
.await
|
||||
.expect("缓存命中失败");
|
||||
assert!(second.from_cache);
|
||||
assert_eq!(second.bytes, payload.len() as u64);
|
||||
assert_eq!(server.hits(), hits_after_first, "缓存命中不能再发请求");
|
||||
assert_eq!(
|
||||
std::fs::read(harness.dir.join("b/lib.jar")).unwrap(),
|
||||
payload
|
||||
);
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resumes_from_existing_part_file() {
|
||||
let payload = b"0123456789abcdefghij".to_vec();
|
||||
let split = 8_usize;
|
||||
let server = MockServer::start(vec![MockResponse::ok(payload.clone())]).await;
|
||||
let url = server.url("/client.jar");
|
||||
let harness = harness(
|
||||
fast_config(2),
|
||||
MirrorTable::builtin(),
|
||||
direct_region(),
|
||||
"engine-resume",
|
||||
);
|
||||
let dest = harness.dir.join("client.jar");
|
||||
let part = harness.dir.join("client.jar.ullpart");
|
||||
let meta_path = harness.dir.join("client.jar.ullpart.ullmeta.json");
|
||||
|
||||
std::fs::write(&part, &payload[..split]).unwrap();
|
||||
ResumeMeta {
|
||||
url: url.clone(),
|
||||
etag: Some("\"v1\"".to_string()),
|
||||
last_modified: None,
|
||||
total: Some(payload.len() as u64),
|
||||
algo: HashAlgo::Sha1,
|
||||
hash: sha1_hex(&payload),
|
||||
saved_at_ms: 1,
|
||||
}
|
||||
.save(&meta_path)
|
||||
.expect("写续传元数据失败");
|
||||
|
||||
let outcome = harness
|
||||
.engine
|
||||
.download(
|
||||
DownloadRequest::new(ResourceKind::ClientJar, url.as_str(), &dest)
|
||||
.with_sha1(sha1_hex(&payload))
|
||||
.with_size(payload.len() as u64),
|
||||
)
|
||||
.await
|
||||
.expect("续传失败");
|
||||
|
||||
assert_eq!(outcome.resumed_from, split as u64, "应从已有偏移续传");
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), payload);
|
||||
assert_eq!(server.hits(), 1);
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reports_progress_events_and_errors_when_all_sources_fail() {
|
||||
let server = MockServer::start(vec![
|
||||
MockResponse::status(500),
|
||||
MockResponse::status(500),
|
||||
MockResponse::status(500),
|
||||
MockResponse::status(500),
|
||||
])
|
||||
.await;
|
||||
let url = server.url("/dead.jar");
|
||||
let dir = temp_dir("engine-fail");
|
||||
let cache = Arc::new(ContentCache::open(dir.join("cache")));
|
||||
let (emitter, mut events) = ProgressEmitter::channel();
|
||||
let engine = DownloadEngine::new(
|
||||
fast_config(1),
|
||||
Arc::new(MirrorTable::builtin()),
|
||||
Arc::new(direct_region()),
|
||||
Arc::new(HealthRegistry::in_memory()),
|
||||
cache,
|
||||
)
|
||||
.with_progress(emitter);
|
||||
|
||||
let error = engine
|
||||
.download(DownloadRequest::new(
|
||||
ResourceKind::Library,
|
||||
url.as_str(),
|
||||
dir.join("dead.jar"),
|
||||
))
|
||||
.await
|
||||
.expect_err("所有源都 500 时必须失败");
|
||||
assert_eq!(error.kind, DownloadErrorKind::AllSourcesFailed);
|
||||
|
||||
let mut started = false;
|
||||
let mut failed_source = false;
|
||||
while let Ok(event) = events.try_recv() {
|
||||
match event {
|
||||
ProgressEvent::Started { candidates, .. } => {
|
||||
started = true;
|
||||
assert!(candidates >= 1);
|
||||
}
|
||||
ProgressEvent::SourceFailed { will_retry, .. } => {
|
||||
failed_source = true;
|
||||
assert!(!will_retry, "单次尝试用尽后不应再声明重试");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert!(started && failed_source, "进度事件缺失");
|
||||
server.stop();
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
//! 健康度记忆的单测:冷却、恢复探测、排序、持久化 + 引擎驱动的落盘接线。
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
//! 镜像清单与前缀重写的单测(纯字符串,无网络)。
|
||||
|
||||
use unlucky_core::download::adoptium::{parse_adoptium_artifact, JdkFlavor};
|
||||
use unlucky_core::download::mirrors::{host_of, source_id_for, MirrorTable, PrefixRule};
|
||||
use unlucky_core::download::region::RegionProfile;
|
||||
use unlucky_core::download::types::ResourceKind;
|
||||
|
||||
const LIBRARIES_URL: &str =
|
||||
"https://libraries.minecraft.net/com/google/guava/guava/32.0.0/guava-32.0.0.jar";
|
||||
const ASSET_URL: &str = "https://resources.download.minecraft.net/ab/abcdef0123456789";
|
||||
const GITHUB_URL: &str =
|
||||
"https://github.com/AskaEth/Unlucky-Launcher/releases/download/v1.0.0/ull-windows-x64.exe";
|
||||
const ADOPTIUM_URL: &str = "https://github.com/adoptium/temurin17-binaries/releases/download/JDK17.0.13%2B11/OpenJDK17U-jdk_x64_windows_hotspot_17.0.13_11.zip";
|
||||
|
||||
#[test]
|
||||
fn builtin_table_is_embedded_and_complete() {
|
||||
let table = MirrorTable::builtin();
|
||||
assert!(table.version >= 1);
|
||||
assert!(table.rules.len() >= 10, "内置规则太少");
|
||||
assert!(table.adoptium.len() >= 3, "院校镜像至少要 TUNA/USTC/SJTU");
|
||||
assert!(!table.gh_proxies.is_empty());
|
||||
assert!(table.gh_proxies[0].prefix.starts_with("https://gh-proxy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_rule_preserves_sub_path() {
|
||||
let table = MirrorTable::builtin();
|
||||
let rule = table
|
||||
.prefix_rule_for(LIBRARIES_URL, ResourceKind::Library)
|
||||
.expect("libraries 规则缺失");
|
||||
let candidate = rule.apply(LIBRARIES_URL).expect("重写失败");
|
||||
assert_eq!(candidate.source, "bmclapi");
|
||||
assert_eq!(
|
||||
candidate.url,
|
||||
LIBRARIES_URL.replace(
|
||||
"https://libraries.minecraft.net",
|
||||
"https://bmclapi2.bangbang93.com"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_rule_injects_path_prefix() {
|
||||
let table = MirrorTable::builtin();
|
||||
let candidate = table
|
||||
.prefix_rule_for(ASSET_URL, ResourceKind::Asset)
|
||||
.and_then(|rule| rule.apply(ASSET_URL))
|
||||
.expect("资源对象规则缺失");
|
||||
assert_eq!(
|
||||
candidate.url,
|
||||
"https://bmclapi2.bangbang93.com/assets/ab/abcdef0123456789"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_mode_puts_official_first() {
|
||||
let table = MirrorTable::builtin();
|
||||
let region = RegionProfile::direct(0);
|
||||
let candidates = table.expand(LIBRARIES_URL, ResourceKind::Library, ®ion);
|
||||
assert_eq!(candidates[0].source, "official");
|
||||
assert_eq!(candidates[0].url, LIBRARIES_URL);
|
||||
// 镜像仍然保留为兜底(官方失败时能救回来)。
|
||||
assert!(candidates
|
||||
.iter()
|
||||
.any(|candidate| candidate.source == "bmclapi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_first_mode_puts_mirror_first() {
|
||||
let table = MirrorTable::builtin();
|
||||
let region = RegionProfile::mirror_first(0);
|
||||
let candidates = table.expand(LIBRARIES_URL, ResourceKind::Library, ®ion);
|
||||
assert_eq!(candidates[0].source, "bmclapi");
|
||||
assert_eq!(
|
||||
candidates.last().map(|c| c.source.as_str()),
|
||||
Some("official")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gh_proxy_only_for_github_hosts_in_domestic_mode() {
|
||||
let table = MirrorTable::builtin();
|
||||
let domestic = RegionProfile::mirror_first(0);
|
||||
let direct = RegionProfile::direct(0);
|
||||
|
||||
let github = table.expand(GITHUB_URL, ResourceKind::GitHubRelease, &domestic);
|
||||
assert!(
|
||||
github.iter().any(|candidate| candidate.source == "gh_proxy"
|
||||
&& candidate.url.starts_with("https://gh-proxy.com/")),
|
||||
"国内模式下 GitHub 资源必须套 gh-proxy:{github:?}"
|
||||
);
|
||||
|
||||
let non_github = table.expand(LIBRARIES_URL, ResourceKind::Library, &domestic);
|
||||
assert!(
|
||||
non_github
|
||||
.iter()
|
||||
.all(|candidate| candidate.source != "gh_proxy"),
|
||||
"非 GitHub 资源不受 gh-proxy 影响"
|
||||
);
|
||||
|
||||
let github_direct = table.expand(GITHUB_URL, ResourceKind::GitHubRelease, &direct);
|
||||
assert!(github_direct
|
||||
.iter()
|
||||
.all(|candidate| candidate.source != "gh_proxy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_or_disabled_gh_proxy_is_respected() {
|
||||
let table = MirrorTable::builtin();
|
||||
|
||||
// 用户关掉:地区档案显式不带前缀 → 一条 gh_proxy 候选都不出。
|
||||
let off = RegionProfile::mirror_first(0).with_gh_proxy(None);
|
||||
let candidates = table.expand(GITHUB_URL, ResourceKind::GitHubRelease, &off);
|
||||
assert!(candidates.iter().all(|c| c.source != "gh_proxy"));
|
||||
|
||||
// 自定义前缀(设置项写进地区档案)。
|
||||
let custom = RegionProfile::mirror_first(0).with_gh_proxy(Some("https://proxy.example/"));
|
||||
let candidates = table.expand(GITHUB_URL, ResourceKind::GitHubRelease, &custom);
|
||||
let proxy = candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.source == "gh_proxy")
|
||||
.expect("自定义前缀未生效");
|
||||
assert_eq!(proxy.url, format!("https://proxy.example/{GITHUB_URL}"));
|
||||
|
||||
// 清单给设置做默认值。
|
||||
assert_eq!(table.default_gh_proxy(), Some("https://gh-proxy.com/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adoptium_artifact_parsing_and_mirror_ordering() {
|
||||
let parsed = parse_adoptium_artifact(ADOPTIUM_URL).expect("解析 Temurin 产物名失败");
|
||||
assert_eq!(parsed.major, "17");
|
||||
assert_eq!(parsed.flavor, JdkFlavor::Jdk);
|
||||
assert_eq!(parsed.arch, "x64");
|
||||
assert_eq!(parsed.os, "windows");
|
||||
assert!(parsed.filename.starts_with("OpenJDK17U-jdk_x64_windows"));
|
||||
|
||||
let table = MirrorTable::builtin();
|
||||
let region = RegionProfile::mirror_first(0);
|
||||
let candidates = table.expand(ADOPTIUM_URL, ResourceKind::JavaRuntime, ®ion);
|
||||
let first = &candidates[0];
|
||||
assert_eq!(first.source, "tuna");
|
||||
assert!(
|
||||
first
|
||||
.url
|
||||
.starts_with("https://mirrors.tuna.tsinghua.edu.cn/Adoptium/17/jdk/x64/windows/"),
|
||||
"院校镜像路径不符合模板:{}",
|
||||
first.url
|
||||
);
|
||||
// 官方仍在候选里(全部镜像不可达时的兜底)。
|
||||
assert!(candidates
|
||||
.iter()
|
||||
.any(|candidate| candidate.source == "official"));
|
||||
}
|
||||
|
||||
/// JRE 产物必须落到镜像的 `/jre/` 子树(否则 4 个镜像全 404,加速白费)。
|
||||
#[test]
|
||||
fn adoptium_jre_uses_jre_path_segment() {
|
||||
let jre_url = ADOPTIUM_URL.replace("OpenJDK17U-jdk_", "OpenJDK17U-jre_");
|
||||
let parsed = parse_adoptium_artifact(&jre_url).expect("解析 JRE 产物名失败");
|
||||
assert_eq!(parsed.flavor, JdkFlavor::Jre);
|
||||
assert_eq!(
|
||||
parsed.mirror_url("https://mirrors.tuna.tsinghua.edu.cn/Adoptium"),
|
||||
format!(
|
||||
"https://mirrors.tuna.tsinghua.edu.cn/Adoptium/17/jre/x64/windows/{}",
|
||||
parsed.filename
|
||||
)
|
||||
);
|
||||
|
||||
let table = MirrorTable::builtin();
|
||||
let candidates = table.expand(
|
||||
&jre_url,
|
||||
ResourceKind::JavaRuntime,
|
||||
&RegionProfile::mirror_first(0),
|
||||
);
|
||||
let first = &candidates[0];
|
||||
assert_eq!(first.source, "tuna");
|
||||
assert!(
|
||||
first.url.contains("/17/jre/x64/windows/"),
|
||||
"JRE 必须走 /jre/ 子树:{}",
|
||||
first.url
|
||||
);
|
||||
|
||||
// `-jre-17…` 这种兜底形态同样识别。
|
||||
let fallback = "https://github.com/adoptium/temurin21-binaries/releases/download/jre-21.0.5%2B11/OpenJDK21U-jre_x64_linux_hotspot_21.0.5_11.tar.gz";
|
||||
assert_eq!(
|
||||
parse_adoptium_artifact(fallback).map(|artifact| artifact.flavor),
|
||||
Some(JdkFlavor::Jre)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_rules_take_precedence() {
|
||||
let mut table = MirrorTable::builtin();
|
||||
table.add_custom_rule(PrefixRule::new(
|
||||
"my-mirror",
|
||||
"https://libraries.minecraft.net",
|
||||
"https://mirror.example/libraries",
|
||||
));
|
||||
let candidate = table
|
||||
.prefix_rule_for(LIBRARIES_URL, ResourceKind::Library)
|
||||
.and_then(|rule| rule.apply(LIBRARIES_URL))
|
||||
.expect("自定义规则未生效");
|
||||
assert_eq!(candidate.source, "mirror.example");
|
||||
assert_eq!(
|
||||
candidate.url,
|
||||
"https://mirror.example/libraries/com/google/guava/guava/32.0.0/guava-32.0.0.jar"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_rule_keeps_official_first_even_in_domestic_mode() {
|
||||
let mut table = MirrorTable {
|
||||
version: 1,
|
||||
notes: Vec::new(),
|
||||
rules: vec![PrefixRule::new(
|
||||
"modrinth-accelerator",
|
||||
"https://api.modrinth.com",
|
||||
"https://cdn.example",
|
||||
)
|
||||
.fallback()],
|
||||
// v3 新增的三张 JDK 源表在这张夹具里必须为空(否则会混进别的候选)。
|
||||
adoptium: Vec::new(),
|
||||
oracle_openjdk: Vec::new(),
|
||||
microsoft_openjdk: None,
|
||||
zulu: None,
|
||||
gh_proxies: Vec::new(),
|
||||
};
|
||||
let region = RegionProfile::mirror_first(0);
|
||||
let candidates = table.expand(
|
||||
"https://api.modrinth.com/v2/project/sodium",
|
||||
ResourceKind::ModFile,
|
||||
®ion,
|
||||
);
|
||||
assert_eq!(
|
||||
candidates[0].source, "official",
|
||||
"「仅加速」规则不能顶掉官方"
|
||||
);
|
||||
assert_eq!(candidates[1].source, "cdn.example");
|
||||
|
||||
// 关掉规则后只剩官方一条。
|
||||
table.rules.clear();
|
||||
let candidates = table.expand(
|
||||
"https://api.modrinth.com/v2/project/sodium",
|
||||
ResourceKind::ModFile,
|
||||
®ion,
|
||||
);
|
||||
assert_eq!(candidates.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_and_source_id_helpers() {
|
||||
assert_eq!(
|
||||
host_of("https://libraries.minecraft.net/a/b"),
|
||||
Some("libraries.minecraft.net")
|
||||
);
|
||||
assert_eq!(
|
||||
host_of("https://user:pass@mirror.example:8443/x/y"),
|
||||
Some("mirror.example")
|
||||
);
|
||||
assert_eq!(host_of("http://127.0.0.1:8080/file"), Some("127.0.0.1"));
|
||||
assert_eq!(host_of("not-a-url"), Some("not-a-url"));
|
||||
|
||||
assert_eq!(
|
||||
source_id_for("https://bmclapi2.bangbang93.com/assets/ab/cd"),
|
||||
"bmclapi"
|
||||
);
|
||||
assert_eq!(
|
||||
source_id_for("https://mirrors.tuna.tsinghua.edu.cn/Adoptium/17/x.zip"),
|
||||
"tuna"
|
||||
);
|
||||
assert_eq!(
|
||||
source_id_for("https://mirrors.ustc.edu.cn/adoptium/17/x.zip"),
|
||||
"ustc"
|
||||
);
|
||||
assert_eq!(
|
||||
source_id_for("https://gh-proxy.com/https://github.com/a/b"),
|
||||
"gh_proxy"
|
||||
);
|
||||
assert_eq!(source_id_for("https://example.org/file"), "example.org");
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! `download::atomic::PartFile`:中间文件的进程/实例隔离、续传收养与孤儿清理。
|
||||
//!
|
||||
//! 背景(M35 真机 finding):中间文件路径原先只由目标路径决定,两个启动器进程装同一
|
||||
//! 数据根时共用一个 `.ullpart`,先提交的一方 `rename` 走,另一方 `os error 2`
|
||||
//! (实测两次 `ull install` 各失败 819 / 1116 个资源)。这里守住三条:名字带会话标识、
|
||||
//! 旧材料能收养、孤儿清理不许误删有人在用的中间文件。
|
||||
|
||||
mod support;
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use unlucky_core::download::backoff::BackoffPolicy;
|
||||
use unlucky_core::download::cache::ContentCache;
|
||||
use unlucky_core::download::chunked::ChunkedConfig;
|
||||
use unlucky_core::download::config::EngineConfig;
|
||||
use unlucky_core::download::engine::DownloadEngine;
|
||||
use unlucky_core::download::health::HealthRegistry;
|
||||
use unlucky_core::download::mirrors::MirrorTable;
|
||||
use unlucky_core::download::region::RegionProfile;
|
||||
use unlucky_core::download::types::{DownloadRequest, ResourceKind};
|
||||
use unlucky_core::download::{PartFile, ADOPT_MIN_AGE, META_SUFFIX, ORPHAN_MAX_AGE, PART_SUFFIX};
|
||||
|
||||
use support::{age_file, part_files, sha1_hex, temp_dir, MockResponse, MockServer};
|
||||
|
||||
/// 另一个会话号(保证与本进程不同)。
|
||||
fn foreign_session() -> u32 {
|
||||
std::process::id() ^ 1
|
||||
}
|
||||
|
||||
fn part_name(name: &str, session: u32, seq: u64) -> String {
|
||||
format!("{name}{PART_SUFFIX}-{session}-{seq}")
|
||||
}
|
||||
|
||||
fn write_part(dir: &Path, name: &str) -> PathBuf {
|
||||
let path = dir.join(name);
|
||||
fs::write(&path, b"0123456789").expect("写中间文件失败");
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn part_file_names_carry_session_and_sequence() {
|
||||
let dir = temp_dir("partfile-identity");
|
||||
let dest = dir.join("client.jar");
|
||||
|
||||
let first = PartFile::for_dest(&dest);
|
||||
let second = PartFile::for_dest(&dest);
|
||||
|
||||
assert_ne!(
|
||||
first.path(),
|
||||
second.path(),
|
||||
"同一 dest 的两个守卫不能共用文件"
|
||||
);
|
||||
assert_eq!(first.path().parent(), Some(dir.as_path()));
|
||||
let name = first.path().file_name().unwrap().to_string_lossy();
|
||||
assert!(
|
||||
name.starts_with(&format!("client.jar{PART_SUFFIX}-")),
|
||||
"中间文件名要带进程标识:{name}"
|
||||
);
|
||||
assert!(
|
||||
name.contains(&std::process::id().to_string()),
|
||||
"进程号要在名字里(跨进程隔离的依据):{name}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_threads_writing_the_same_dest_commit_independently() {
|
||||
let dir = temp_dir("partfile-threads");
|
||||
let dest = dir.join("client.jar");
|
||||
|
||||
let handles: Vec<_> = (0..2_u8)
|
||||
.map(|index| {
|
||||
let dest = dest.clone();
|
||||
std::thread::spawn(move || {
|
||||
let payload = vec![b'a' + index; 4096];
|
||||
let part = PartFile::for_dest(&dest);
|
||||
part.open_truncate()
|
||||
.expect("打开中间文件失败")
|
||||
.write_all(&payload)
|
||||
.expect("写中间文件失败");
|
||||
part.commit(&dest).expect("原子落盘失败");
|
||||
payload
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let payloads: Vec<Vec<u8>> = handles
|
||||
.into_iter()
|
||||
.map(|handle| handle.join().expect("下载线程 panic"))
|
||||
.collect();
|
||||
let written = fs::read(&dest).expect("读落盘文件失败");
|
||||
assert!(
|
||||
payloads.contains(&written),
|
||||
"落盘内容必须是某个线程的完整产物"
|
||||
);
|
||||
assert!(
|
||||
part_files(&dir).is_empty(),
|
||||
"两线程各自的中间文件都要被提交掉:{:?}",
|
||||
part_files(&dir)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_part_file_is_adopted_for_resume() {
|
||||
let dir = temp_dir("partfile-legacy");
|
||||
let dest = dir.join("client.jar");
|
||||
let legacy = write_part(&dir, &format!("client.jar{PART_SUFFIX}"));
|
||||
let legacy_meta = dir.join(format!("client.jar{PART_SUFFIX}{META_SUFFIX}"));
|
||||
fs::write(&legacy_meta, b"{}").expect("写元数据失败");
|
||||
|
||||
let part = PartFile::for_dest(&dest);
|
||||
|
||||
assert_ne!(part.path(), legacy.as_path(), "新守卫要换自己的名字");
|
||||
assert_eq!(part.len(), 10, "旧版中间文件必须被收养(续传语义不变)");
|
||||
assert!(!legacy.exists(), "收养后不许留下旧名字");
|
||||
assert!(!legacy_meta.exists(), "元数据要跟着中间文件一起搬");
|
||||
assert!(part.meta_path().is_file(), "收养后的元数据路径要就位");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_part_file_is_adopted_only_after_it_cools_down() {
|
||||
let dir = temp_dir("partfile-foreign");
|
||||
let dest = dir.join("client.jar");
|
||||
let foreign = write_part(&dir, &part_name("client.jar", foreign_session(), 1));
|
||||
|
||||
let fresh = PartFile::for_dest(&dest);
|
||||
assert!(!fresh.exists(), "外进程刚写过的中间文件不能抢");
|
||||
assert!(foreign.is_file(), "更不能删(可能正被那个进程写着)");
|
||||
|
||||
age_file(&foreign, ADOPT_MIN_AGE + Duration::from_secs(60));
|
||||
let adopted = PartFile::for_dest(&dest);
|
||||
assert_eq!(adopted.len(), 10, "静置够久的外来中间文件应被收养续传");
|
||||
assert!(!foreign.exists(), "收养后旧名字应消失");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweeps_ownerless_orphans_but_keeps_fresh_and_live_files() {
|
||||
let dir = temp_dir("partfile-sweep");
|
||||
let stale = write_part(&dir, &format!("other.jar{PART_SUFFIX}"));
|
||||
let stale_meta = dir.join(format!("other.jar{PART_SUFFIX}{META_SUFFIX}"));
|
||||
fs::write(&stale_meta, b"{}").expect("写元数据失败");
|
||||
age_file(&stale, ORPHAN_MAX_AGE + Duration::from_secs(60));
|
||||
let fresh = write_part(&dir, &format!("fresh.jar{PART_SUFFIX}"));
|
||||
|
||||
let _guard = PartFile::for_dest(&dir.join("client.jar"));
|
||||
|
||||
assert!(!stale.exists(), "无主且超龄的中间文件应被清理");
|
||||
assert!(!stale_meta.exists(), "它的元数据也要一起清");
|
||||
assert_eq!(
|
||||
fs::read(&fresh).unwrap().len(),
|
||||
10,
|
||||
"还新鲜的无主文件不能删"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_another_live_guard_of_the_same_dest() {
|
||||
let dir = temp_dir("partfile-live");
|
||||
let dest = dir.join("client.jar");
|
||||
let running = PartFile::for_dest(&dest);
|
||||
running
|
||||
.open_truncate()
|
||||
.expect("打开中间文件失败")
|
||||
.write_all(&[b'x'; 1024])
|
||||
.expect("写中间文件失败");
|
||||
|
||||
let other = PartFile::for_dest(&dest);
|
||||
|
||||
assert!(
|
||||
running.path().is_file(),
|
||||
"有人在用的中间文件不能被清理或收养"
|
||||
);
|
||||
assert_eq!(running.len(), 1024);
|
||||
assert_ne!(other.path(), running.path());
|
||||
}
|
||||
|
||||
fn fast_engine(cache: &Path) -> DownloadEngine {
|
||||
DownloadEngine::new(
|
||||
EngineConfig {
|
||||
max_attempts_per_source: 1,
|
||||
backoff: BackoffPolicy {
|
||||
base_ms: 1,
|
||||
max_ms: 2,
|
||||
jitter_ratio: 0.0,
|
||||
max_attempts: 1,
|
||||
},
|
||||
read_timeout: Duration::from_secs(5),
|
||||
connect_timeout: Duration::from_secs(5),
|
||||
chunked: ChunkedConfig::disabled(),
|
||||
..EngineConfig::default()
|
||||
},
|
||||
Arc::new(MirrorTable::builtin()),
|
||||
Arc::new(RegionProfile::direct(0)),
|
||||
Arc::new(HealthRegistry::in_memory()),
|
||||
Arc::new(ContentCache::open(cache)),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn two_engines_downloading_the_same_dest_do_not_share_parts() {
|
||||
let payload = b"same destination payload".to_vec();
|
||||
let server = MockServer::start(vec![
|
||||
MockResponse::ok(payload.clone()),
|
||||
MockResponse::ok(payload.clone()),
|
||||
])
|
||||
.await;
|
||||
let url = server.url("/client.jar");
|
||||
let dir = temp_dir("partfile-engine");
|
||||
let dest = dir.join("client.jar");
|
||||
// 两份独立的缓存目录:否则后到的那次会直接吃内容缓存,绕过中间文件路径。
|
||||
let left = fast_engine(&dir.join("cache-a"));
|
||||
let right = fast_engine(&dir.join("cache-b"));
|
||||
let request = || {
|
||||
DownloadRequest::new(ResourceKind::ClientJar, url.as_str(), &dest)
|
||||
.with_sha1(sha1_hex(&payload))
|
||||
.with_size(payload.len() as u64)
|
||||
};
|
||||
|
||||
let (first, second) = tokio::join!(left.download(request()), right.download(request()));
|
||||
|
||||
first.expect("并行下载 A 失败");
|
||||
second.expect("并行下载 B 失败");
|
||||
assert_eq!(fs::read(&dest).expect("读落盘文件失败"), payload);
|
||||
assert!(
|
||||
part_files(&dir).is_empty(),
|
||||
"并发下载不能留下中间文件:{:?}",
|
||||
part_files(&dir)
|
||||
);
|
||||
server.stop();
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! 地区判定的单测:只测纯判定与缓存,不真发探测请求。
|
||||
|
||||
use unlucky_core::download::region::{
|
||||
decide_from, load_cached, store_cached, RegionMode, RegionProfile, RegionTrigger, Signals,
|
||||
DECISION_TTL_MS, DEFAULT_GH_PROXY,
|
||||
};
|
||||
|
||||
fn signals(timezone: Option<&str>, language: Option<&str>, google: Option<bool>) -> Signals {
|
||||
Signals {
|
||||
timezone: timezone.map(str::to_string),
|
||||
languages: language.map(str::to_string).into_iter().collect(),
|
||||
google_reachable: google,
|
||||
baidu_reachable: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timezone_signal_detects_greater_china() {
|
||||
assert!(signals(Some("Asia/Shanghai"), None, None).is_greater_china());
|
||||
assert!(signals(Some("Asia/Taipei"), None, None).is_greater_china());
|
||||
// 大小写/空白容错。
|
||||
assert!(signals(Some(" asia/hong_kong "), None, None).is_greater_china());
|
||||
assert!(!signals(Some("Europe/Berlin"), None, None).is_greater_china());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_signal_handles_posix_and_bcp47() {
|
||||
assert!(signals(None, Some("zh_CN.UTF-8"), None).is_greater_china());
|
||||
assert!(signals(None, Some("zh-Hant-TW"), None).is_greater_china());
|
||||
assert!(!signals(None, Some("en_US.UTF-8"), None).is_greater_china());
|
||||
// 时区不命中、语言命中也算。
|
||||
assert!(signals(Some("UTC"), Some("zh_TW"), None).is_greater_china());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domestic_without_google_goes_mirror_first() {
|
||||
let profile = decide_from(&signals(Some("Asia/Shanghai"), None, Some(false)), 1);
|
||||
assert_eq!(profile.mode, RegionMode::MirrorFirst);
|
||||
assert!(profile.greater_china);
|
||||
assert_eq!(profile.trigger, RegionTrigger::Auto);
|
||||
assert_eq!(profile.gh_proxy.as_deref(), Some(DEFAULT_GH_PROXY));
|
||||
assert!(profile.gh_proxy_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domestic_with_google_reachable_stays_direct() {
|
||||
// 已有全局代理:尊重用户代理,不再插镜像。
|
||||
let profile = decide_from(&signals(Some("Asia/Shanghai"), None, Some(true)), 1);
|
||||
assert_eq!(profile.mode, RegionMode::Direct);
|
||||
assert!(profile.gh_proxy.is_none());
|
||||
assert!(!profile.gh_proxy_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_greater_china_is_always_direct() {
|
||||
for google in [Some(true), Some(false), None] {
|
||||
let profile = decide_from(&signals(Some("America/New_York"), Some("en-US"), google), 1);
|
||||
assert_eq!(profile.mode, RegionMode::Direct, "google={google:?}");
|
||||
assert!(!profile.greater_china);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probes_disabled_defaults_to_mirror_first_for_chinese_env() {
|
||||
let profile = decide_from(&signals(None, Some("zh_CN.UTF-8"), None), 1);
|
||||
assert_eq!(profile.mode, RegionMode::MirrorFirst);
|
||||
assert!(!profile.probes_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_mode_never_goes_stale() {
|
||||
let pinned = RegionProfile::pinned(RegionMode::Direct, 10);
|
||||
assert_eq!(pinned.trigger, RegionTrigger::UserPinned);
|
||||
assert!(!pinned.is_stale(10 + DECISION_TTL_MS * 10));
|
||||
|
||||
let auto = decide_from(&signals(Some("Asia/Shanghai"), None, Some(false)), 10);
|
||||
assert!(!auto.is_stale(10 + 1_000));
|
||||
assert!(auto.is_stale(10 + DECISION_TTL_MS + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gh_proxy_helpers() {
|
||||
let mut profile = RegionProfile::mirror_first(0);
|
||||
assert!(profile.gh_proxy_enabled());
|
||||
profile = profile.with_gh_proxy(Some(" "));
|
||||
assert!(!profile.gh_proxy_enabled(), "空白前缀视为不启用");
|
||||
profile = profile.with_gh_proxy(Some("https://proxy.example/"));
|
||||
assert!(profile.gh_proxy_enabled());
|
||||
|
||||
// 直连模式下即便配了前缀也不生效(gh-proxy 只在国内模式用)。
|
||||
let direct = RegionProfile::direct(0).with_gh_proxy(Some("https://proxy.example/"));
|
||||
assert!(!direct.gh_proxy_enabled());
|
||||
assert_eq!(direct.label(), "直连");
|
||||
assert_eq!(RegionProfile::mirror_first(0).label(), "镜像优先");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_roundtrip_and_ttl_expiry() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-region-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("region.json");
|
||||
|
||||
let profile = decide_from(&signals(Some("Asia/Shanghai"), None, Some(false)), 1_000);
|
||||
store_cached(&path, &profile).expect("写缓存失败");
|
||||
|
||||
let cached = load_cached(&path, 1_000 + 60_000).expect("应有缓存");
|
||||
assert_eq!(cached, profile);
|
||||
|
||||
// 过期 → 视为没有缓存。
|
||||
assert!(load_cached(&path, 1_000 + DECISION_TTL_MS + 1).is_none());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_cache_file_is_not_an_error() {
|
||||
let path = std::env::temp_dir().join("ull-region-does-not-exist.json");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert!(load_cached(&path, 0).is_none());
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! 断点续传保守判定的单测:矩阵覆盖 + 416 处理。
|
||||
|
||||
use unlucky_core::download::resume::{
|
||||
decide, on_range_unsatisfiable, RestartReason, ResumeDecision, ResumeMeta, Validator,
|
||||
};
|
||||
use unlucky_core::download::types::{HashAlgo, ResumePolicy};
|
||||
|
||||
const URL: &str = "https://bmclapi2.bangbang93.com/versions/1.20.1/client.jar";
|
||||
const HASH: &str = "0123456789abcdef0123456789abcdef01234567";
|
||||
|
||||
fn meta(url: &str) -> ResumeMeta {
|
||||
ResumeMeta {
|
||||
url: url.to_string(),
|
||||
etag: Some("\"abc\"".to_string()),
|
||||
last_modified: None,
|
||||
total: Some(1_000),
|
||||
algo: HashAlgo::Sha1,
|
||||
hash: HASH.to_string(),
|
||||
saved_at_ms: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn resume_ok(part_len: u64, meta: &ResumeMeta) -> ResumeDecision {
|
||||
decide(
|
||||
ResumePolicy::Conservative,
|
||||
part_len,
|
||||
Some(meta),
|
||||
URL,
|
||||
Some((HashAlgo::Sha1, HASH)),
|
||||
Some(1_000),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumes_when_everything_matches() {
|
||||
let decision = resume_ok(400, &meta(URL));
|
||||
assert_eq!(
|
||||
decision,
|
||||
ResumeDecision::Resume {
|
||||
offset: 400,
|
||||
validator: Validator {
|
||||
etag: Some("\"abc\"".to_string()),
|
||||
last_modified: None,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_policy_and_missing_files_restart() {
|
||||
let meta = meta(URL);
|
||||
assert_eq!(
|
||||
decide(
|
||||
ResumePolicy::Never,
|
||||
400,
|
||||
Some(&meta),
|
||||
URL,
|
||||
Some((HashAlgo::Sha1, HASH)),
|
||||
Some(1_000)
|
||||
),
|
||||
ResumeDecision::Restart {
|
||||
reason: RestartReason::PolicyNever
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
resume_ok(0, &meta),
|
||||
ResumeDecision::Restart {
|
||||
reason: RestartReason::EmptyPart
|
||||
}
|
||||
);
|
||||
// 没有元数据:无法证明中间文件是同一次下载的产物。
|
||||
assert_eq!(
|
||||
decide(
|
||||
ResumePolicy::Conservative,
|
||||
400,
|
||||
None,
|
||||
URL,
|
||||
Some((HashAlgo::Sha1, HASH)),
|
||||
Some(1_000)
|
||||
),
|
||||
ResumeDecision::Restart {
|
||||
reason: RestartReason::NoMeta
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_url_or_hash_restarts() {
|
||||
assert_eq!(
|
||||
decide(
|
||||
ResumePolicy::Conservative,
|
||||
400,
|
||||
Some(&meta("https://other.example/client.jar")),
|
||||
URL,
|
||||
Some((HashAlgo::Sha1, HASH)),
|
||||
Some(1_000)
|
||||
),
|
||||
ResumeDecision::Restart {
|
||||
reason: RestartReason::UrlChanged
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
decide(
|
||||
ResumePolicy::Conservative,
|
||||
400,
|
||||
Some(&meta(URL)),
|
||||
URL,
|
||||
Some((HashAlgo::Sha1, "ffffffffffffffffffffffffffffffffffffffff")),
|
||||
Some(1_000)
|
||||
),
|
||||
ResumeDecision::Restart {
|
||||
reason: RestartReason::HashChanged
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_total_or_oversize_restarts() {
|
||||
let mut unknown = meta(URL);
|
||||
unknown.total = None;
|
||||
assert_eq!(
|
||||
decide(
|
||||
ResumePolicy::Conservative,
|
||||
400,
|
||||
Some(&unknown),
|
||||
URL,
|
||||
None,
|
||||
None
|
||||
),
|
||||
ResumeDecision::Restart {
|
||||
reason: RestartReason::TotalUnknown
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resume_ok(2_000, &meta(URL)),
|
||||
ResumeDecision::Restart {
|
||||
reason: RestartReason::Oversize
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_part_skips_transfer() {
|
||||
assert_eq!(
|
||||
resume_ok(1_000, &meta(URL)),
|
||||
ResumeDecision::Complete { size: 1_000 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_validator_restarts() {
|
||||
let mut weak = meta(URL);
|
||||
weak.etag = None;
|
||||
weak.last_modified = None;
|
||||
assert_eq!(
|
||||
resume_ok(400, &weak),
|
||||
ResumeDecision::Restart {
|
||||
reason: RestartReason::NoValidator
|
||||
}
|
||||
);
|
||||
// Last-Modified 单独也能当校验器。
|
||||
weak.last_modified = Some("Wed, 21 Oct 2015 07:28:00 GMT".to_string());
|
||||
assert!(matches!(
|
||||
resume_ok(400, &weak),
|
||||
ResumeDecision::Resume { offset: 400, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expected_size_can_stand_in_for_missing_total() {
|
||||
let mut unknown = meta(URL);
|
||||
unknown.total = None;
|
||||
let decision = decide(
|
||||
ResumePolicy::Conservative,
|
||||
400,
|
||||
Some(&unknown),
|
||||
URL,
|
||||
Some((HashAlgo::Sha1, HASH)),
|
||||
Some(1_000),
|
||||
);
|
||||
assert!(matches!(
|
||||
decision,
|
||||
ResumeDecision::Resume { offset: 400, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_not_satisfiable_handling() {
|
||||
// 已下满:直接进入校验,不要白删一个好文件。
|
||||
assert_eq!(
|
||||
on_range_unsatisfiable(1_000, Some(1_000)),
|
||||
ResumeDecision::Complete { size: 1_000 }
|
||||
);
|
||||
// 没下满却 416:中间文件不可信,丢掉重来。
|
||||
let decision = on_range_unsatisfiable(400, Some(1_000));
|
||||
assert_eq!(
|
||||
decision,
|
||||
ResumeDecision::Restart {
|
||||
reason: RestartReason::RangeUnsatisfiable
|
||||
}
|
||||
);
|
||||
assert!(decision.should_drop_part());
|
||||
// 不允许丢掉「已完成」的中间文件。
|
||||
assert!(!ResumeDecision::Complete { size: 1 }.should_drop_part());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_meta_roundtrip_and_removal() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-resume-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("client.jar.ullpart.ullmeta.json");
|
||||
|
||||
let original = meta(URL);
|
||||
original.save(&path).expect("写元数据失败");
|
||||
assert_eq!(ResumeMeta::load(&path), Some(original));
|
||||
|
||||
ResumeMeta::remove(&path);
|
||||
assert!(ResumeMeta::load(&path).is_none());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
//! 测试支撑:临时目录、摘要工具、进程内极简 HTTP 服务器(零额外依赖)。
|
||||
//!
|
||||
//! 为什么自己写 mock server 而不用 wiremock:P0 阶段控制依赖面(Win7 Tier-3 构建 +
|
||||
//! 单文件体积),一个 60 行的 `TcpListener` 足够表达「成功 / 500 / 校验不符 / 无视 Range」。
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
/// 断言用的小工具:唯一临时目录(进程内计数 + 时间戳,避免并行测试互相踩)。
|
||||
pub fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
static COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let stamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |since| since.as_nanos());
|
||||
let dir = std::env::temp_dir().join(format!("ull-download-test-{tag}-{stamp}-{unique}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("创建临时目录失败");
|
||||
dir
|
||||
}
|
||||
|
||||
/// 算一段字节的 SHA-1(测试断言用)。
|
||||
pub fn sha1_hex(bytes: &[u8]) -> String {
|
||||
use sha1::Digest;
|
||||
let digest = sha1::Sha1::digest(bytes);
|
||||
let mut out = String::with_capacity(40);
|
||||
for byte in digest {
|
||||
out.push_str(&format!("{byte:02x}"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 目录里(不含子目录)所有中间文件家族的名字(`.ullpart` 与 `.ullmeta.json`)。
|
||||
pub fn part_files(dir: &std::path::Path) -> Vec<String> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut names: Vec<String> = entries
|
||||
.flatten()
|
||||
.map(|entry| entry.file_name().to_string_lossy().into_owned())
|
||||
.filter(|name| name.contains(unlucky_core::download::PART_SUFFIX))
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
/// 把文件的修改时间挪到「`age` 之前」(清理/收养的年龄判定用)。
|
||||
pub fn age_file(path: &std::path::Path, age: std::time::Duration) {
|
||||
let times = std::fs::FileTimes::new().set_modified(std::time::SystemTime::now() - age);
|
||||
std::fs::File::options()
|
||||
.write(true)
|
||||
.open(path)
|
||||
.expect("打开待改 mtime 的文件失败")
|
||||
.set_times(times)
|
||||
.expect("改 mtime 失败");
|
||||
}
|
||||
|
||||
/// mock 服务器给一次请求的应答。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MockResponse {
|
||||
/// HTTP 状态码。
|
||||
pub status: u16,
|
||||
/// 响应体。
|
||||
pub body: Vec<u8>,
|
||||
/// 是否声明 `Accept-Ranges: bytes` 并在收到 `Range` 时真的切段。
|
||||
pub range_support: bool,
|
||||
/// `ETag`(续传测试用)。
|
||||
pub etag: Option<String>,
|
||||
/// 返回 206 时故意把 `Content-Range` 写成从 0 开始(模拟段错位的坏服务器)。
|
||||
pub lie_content_range: bool,
|
||||
}
|
||||
|
||||
impl MockResponse {
|
||||
/// 200 + 完整内容(支持 Range)。
|
||||
pub fn ok(body: impl Into<Vec<u8>>) -> Self {
|
||||
Self {
|
||||
status: 200,
|
||||
body: body.into(),
|
||||
range_support: true,
|
||||
etag: Some("\"v1\"".to_string()),
|
||||
lie_content_range: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 固定状态码 + 空体。
|
||||
pub fn status(status: u16) -> Self {
|
||||
Self {
|
||||
status,
|
||||
body: Vec::new(),
|
||||
range_support: false,
|
||||
etag: None,
|
||||
lie_content_range: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 声明不支持 Range(模拟部分镜像)。
|
||||
pub fn without_range(mut self) -> Self {
|
||||
self.range_support = false;
|
||||
self
|
||||
}
|
||||
|
||||
/// 206 但 `Content-Range` 谎报(模拟段错位的坏 CDN)。
|
||||
pub fn with_wrong_content_range(mut self) -> Self {
|
||||
self.lie_content_range = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 进程内极简 HTTP/1.1 服务器:按脚本顺序返回响应,脚本用尽后一律 500。
|
||||
pub struct MockServer {
|
||||
addr: SocketAddr,
|
||||
hits: Arc<AtomicUsize>,
|
||||
handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl MockServer {
|
||||
/// 启动服务器(脚本是「每次请求一个响应」的队列)。
|
||||
pub async fn start(script: Vec<MockResponse>) -> Self {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("绑定端口失败");
|
||||
let addr = listener.local_addr().expect("取本地地址失败");
|
||||
let hits = Arc::new(AtomicUsize::new(0));
|
||||
let script = Arc::new(std::sync::Mutex::new(script));
|
||||
let counter = Arc::clone(&hits);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((stream, _)) = listener.accept().await else {
|
||||
break;
|
||||
};
|
||||
let script = Arc::clone(&script);
|
||||
let counter = Arc::clone(&counter);
|
||||
tokio::spawn(async move {
|
||||
let index = counter.fetch_add(1, Ordering::Relaxed);
|
||||
let response = {
|
||||
let script = script.lock().unwrap_or_else(|err| err.into_inner());
|
||||
script.get(index).cloned()
|
||||
};
|
||||
let response = response.unwrap_or_else(|| MockResponse::status(500));
|
||||
let _ = serve(stream, response).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Self { addr, hits, handle }
|
||||
}
|
||||
|
||||
/// 第 `index` 个请求该用的 URL(换成不同路径可模拟「不同源」)。
|
||||
pub fn url(&self, path: &str) -> String {
|
||||
format!("http://{}{}", self.addr, path)
|
||||
}
|
||||
|
||||
/// 换 host 拼 URL(用 `localhost` vs `127.0.0.1` 造出两个不同源)。
|
||||
pub fn url_with(&self, host: &str, path: &str) -> String {
|
||||
format!("http://{host}:{}{}", self.addr.port(), path)
|
||||
}
|
||||
|
||||
/// 端口号。
|
||||
pub fn port(&self) -> u16 {
|
||||
self.addr.port()
|
||||
}
|
||||
|
||||
/// 已收到的请求数。
|
||||
pub fn hits(&self) -> usize {
|
||||
self.hits.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// 关停。
|
||||
pub fn stop(self) {
|
||||
self.handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve(mut stream: TcpStream, response: MockResponse) -> std::io::Result<()> {
|
||||
let mut buffer = Vec::new();
|
||||
let mut chunk = [0_u8; 1024];
|
||||
loop {
|
||||
let read = stream.read(&mut chunk).await?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
buffer.extend_from_slice(&chunk[..read]);
|
||||
if buffer.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let request = String::from_utf8_lossy(&buffer).to_ascii_lowercase();
|
||||
let range = request
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("range: bytes="))
|
||||
.map(str::trim)
|
||||
.and_then(parse_range_header);
|
||||
|
||||
let total = response.body.len() as u64;
|
||||
let (status, headers, body) = match (range, response.range_support) {
|
||||
(Some((start, requested_end)), true) if start < total => {
|
||||
// 尊重请求里的结束偏移(分块下载必须逐段精确,否则组装后长度对不上)。
|
||||
let end = requested_end.unwrap_or(total - 1).min(total - 1);
|
||||
let slice = response.body[start as usize..=end as usize].to_vec();
|
||||
// 谎报模式:body 仍是正确的那一段,但头部声称从 0 开始(长度恰好 → 只有
|
||||
// 严格比对 Content-Range 才抓得住)。
|
||||
let (claimed_start, claimed_end) = if response.lie_content_range {
|
||||
(0, slice.len().saturating_sub(1) as u64)
|
||||
} else {
|
||||
(start, end)
|
||||
};
|
||||
(
|
||||
206,
|
||||
vec![
|
||||
format!("Content-Range: bytes {claimed_start}-{claimed_end}/{total}"),
|
||||
format!("Content-Length: {}", slice.len()),
|
||||
"Accept-Ranges: bytes".to_string(),
|
||||
],
|
||||
slice,
|
||||
)
|
||||
}
|
||||
(Some(_), true) => (
|
||||
416,
|
||||
vec![
|
||||
format!("Content-Range: bytes */{total}"),
|
||||
"Content-Length: 0".to_string(),
|
||||
],
|
||||
Vec::new(),
|
||||
),
|
||||
_ => {
|
||||
let mut headers = vec![format!("Content-Length: {total}")];
|
||||
if response.range_support {
|
||||
headers.push("Accept-Ranges: bytes".to_string());
|
||||
}
|
||||
(response.status, headers, response.body.clone())
|
||||
}
|
||||
};
|
||||
let mut head = format!("HTTP/1.1 {status} {}\r\n", reason(status));
|
||||
if let Some(etag) = &response.etag {
|
||||
head.push_str(&format!("ETag: {etag}\r\n"));
|
||||
}
|
||||
for header in headers {
|
||||
head.push_str(&header);
|
||||
head.push_str("\r\n");
|
||||
}
|
||||
head.push_str("Connection: close\r\n\r\n");
|
||||
stream.write_all(head.as_bytes()).await?;
|
||||
stream.write_all(&body).await?;
|
||||
stream.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 解析 `Range: bytes=<start>-<end>`(`end` 可缺省 = 到文件末尾)。
|
||||
fn parse_range_header(value: &str) -> Option<(u64, Option<u64>)> {
|
||||
let (start, end) = value.split_once('-')?;
|
||||
let start = start.trim().parse::<u64>().ok()?;
|
||||
let end = end.trim();
|
||||
let end = if end.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(end.parse::<u64>().ok()?)
|
||||
};
|
||||
Some((start, end))
|
||||
}
|
||||
|
||||
fn reason(status: u16) -> &'static str {
|
||||
match status {
|
||||
200 => "OK",
|
||||
206 => "Partial Content",
|
||||
404 => "Not Found",
|
||||
416 => "Range Not Satisfiable",
|
||||
500 => "Internal Server Error",
|
||||
503 => "Service Unavailable",
|
||||
_ => "Status",
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user