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 开发中。
234 lines
8.3 KiB
Rust
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();
|
|
}
|