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,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();
|
||||
}
|
||||
Reference in new issue
Block a user