Files
Unlucky-Launcher/crates/core/tests/download/partfile_test.rs
T
AskaEth bc41dfcf7d
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
Unlucky Launcher v0.0.1-alpha
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
2026-10-31 21:54:10 +08:00

234 lines
8.3 KiB
Rust

//! `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();
}