Files
Unlucky-Launcher/crates/presenter/src/host/engine.rs
T
AskaEth 46389d61fd feat(m165): 下载引擎按数据根跨请求复用(运行时常驻 + 构造次数守卫)
宿主侧以前每次请求都现建一个 DownloadEngine:连接池、信号量限流、
健康度记忆、内容寻址缓存索引全部清零。现在改成按数据根共享一个实例。

- core: 新增 download::shared(进程级登记处,键 = 缓存根)——
  engine_for / forget / constructions;region/health/cache/mirrors 的装配
  收在这一个 build() 里(持锁构造只做本地 I/O,不联网)
- core: download::blocking 改成进程内唯一常驻运行时。reqwest 的连接池绑在
  驱动连接的运行时上,引擎复用必须配运行时常驻,否则复用的是个装满死连接的池
- presenter: host::engine::engine 返回 Arc<DownloadEngine>(共享实例),
  addon/update/versions/java/authlib 等全部调用点随之改;新增 forget 供
  「重新检测地区」失效(否则进程级引擎下那句话不成立)
- presenter: addon 详情页把「项目 + 版本列表」两次 block_on 收成一次

失败分类 / 镜像回退 / Retry-After 语义未改动(error.rs、http.rs、engine.rs 的
候选循环一字未动)。

守卫(都带变异实证):
- shared::tests::one_engine_per_cache_root + host::engine::tests::
  host_requests_share_one_engine:断言 constructions(root) == 1(不是只看结果)
- shared::tests::pooled_connection_survives_across_block_on_calls:127.0.0.1
  回环服务统计 accept 次数,两次 block_on 必须只拨号一次

验证:cargo test -p unlucky-core --lib(608 过)、5 个 download 集成 target(42 过)、
cargo test -p unlucky-presenter --lib(267 过)、cargo clippy --all-targets、
cargo fmt --check 全绿。真站点(Modrinth/CurseForge/BMCLAPI)未打,见报告。
2026-11-08 09:30:05 +08:00

190 lines
7.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 下载引擎与 Java 落点的装配(versions / install / java 三个任务共用)。
//!
//! **引擎是进程级的、按数据根共享的**(M165):本模块不再自己 `DownloadEngine::new`,而是向
//! [`unlucky_core::download::shared`] 要那个数据根上已经存在的引擎。于是连接池、信号量限流、
//! 镜像健康度、内容寻址缓存都跨请求累积,而不是每次请求清零(逐项代价与边界见那个模块的文档)。
//!
//! 与 CLI `install::build_engine` 同源:内容寻址缓存落 `.ULL/cache/`,地区判定走
//! 24h 缓存 + 环境信号(**不做网络探测**,与 D7 的「不探测时」约定一致);区别只在生命周期
//! ——CLI 一条命令一个进程,建一次就够;GUI 进程要在成百上千次请求之间复用同一份。
use std::path::PathBuf;
use std::sync::Arc;
use unlucky_core::download::shared;
use unlucky_core::download::DownloadEngine;
use unlucky_core::game::java::vendor::JdkVendor;
use unlucky_core::game::java::{self, JavaInstallRoot};
use unlucky_core::platform::paths::ProcessEnv;
use super::env::HostEnv;
/// 取该数据根上**共享**的下载引擎(缓存/健康度/地区判定都在数据根里)。
///
/// 同一个数据根无论被请求多少次,都是同一个引擎;不同数据根各一份(缓存/健康度不串味)。
#[must_use]
pub fn engine(env: &HostEnv) -> Arc<DownloadEngine> {
shared::engine_for(env.layout.cache())
}
/// 让这个数据根的共享引擎失效:下一次 [`engine`] 重新构造(重读地区判定/健康度/缓存索引)。
///
/// 在飞请求手里的 `Arc` 不受影响。设置页「重新检测地区」清掉 `region.json` 之后必须调它,
/// 否则那句话(「下次下载重新判定」)在进程级引擎下就不成立了。
pub fn forget(env: &HostEnv) {
shared::forget(env.layout.cache());
}
/// 是否国内网络(决定默认发行版顺序与镜像优先级)。
pub fn domestic(env: &HostEnv) -> bool {
engine(env).region().is_domestic()
}
/// 设置里的 `java.vendorPriority`(空 = 按地区默认序)。
pub fn java_priority(env: &HostEnv) -> Vec<JdkVendor> {
env.store()
.effective("java.vendorPriority")
.and_then(serde_json::Value::as_array)
.map(|items| {
let texts: Vec<String> = items
.iter()
.filter_map(serde_json::Value::as_str)
.map(str::to_string)
.collect();
unlucky_core::game::java::vendor::parse_priority(&texts)
})
.unwrap_or_default()
}
/// 设置里的 `java.installRoot`(空 = 按数据目录模式派生)。
pub fn java_install_override(env: &HostEnv) -> Option<PathBuf> {
env.store()
.doc()
.effective_str("java.installRoot")
.map(str::trim)
.filter(|text| !text.is_empty())
.map(PathBuf::from)
}
/// 托管 JDK 落点(解析失败 → 用户可读文案)。
pub fn java_install_root(env: &HostEnv) -> Result<JavaInstallRoot, String> {
let override_root = java_install_override(env);
JavaInstallRoot::resolve(
env.mode(),
&env.layout,
override_root.as_deref(),
env.platform,
&ProcessEnv,
)
.map_err(|error| error.to_string())
}
/// 托管 JDK 可能存在的全部安装根(扫描运行时用)。
pub fn java_scan_roots(env: &HostEnv) -> Vec<PathBuf> {
java::location::scan_roots(
env.mode(),
&env.layout,
java_install_override(env).as_deref(),
env.platform,
&ProcessEnv,
)
}
#[cfg(test)]
mod tests {
use super::*;
/// 每个用例一个独立数据根(共享登记处是进程级的,键撞了会互相干扰)。
fn root(tag: &str) -> std::path::PathBuf {
static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!(
"ull-host-engine-{tag}-{}-{seq}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(".ULL").join("cache")).expect("cache");
dir
}
#[test]
fn engine_uses_the_data_root_cache() {
let dir = root("cache");
let env = HostEnv::at_root(&dir);
let engine = engine(&env);
assert_eq!(engine.config().max_concurrency, 64);
// 地区判定不联网:只读缓存/信号
let _ = domestic(&env);
std::fs::remove_dir_all(&dir).ok();
}
/// **M165 的守卫**:同一 host 的多次请求只建一个引擎。
///
/// 断言的是**构造次数**(`shared::constructions`),不是「能不能下到文件」——每请求现建一个
/// 引擎照样能下到文件,只有次数能把「复用」与「没复用」分开。
///
/// 抓得住的变异:把 `core::download::shared::engine_for` 里 `engines.get` 的命中分支删掉
/// (每次调用都现建),本用例红在 `constructions(...) == 1`。
#[test]
fn host_requests_share_one_engine() {
let dir = root("share");
let env = HostEnv::at_root(&dir);
let cache_root = env.layout.cache();
// 三条不同「请求」路径都从 engine() 取引擎(检索 / 清单 / 安装共用这一个漏斗)。
let first = engine(&env);
let second = engine(&env);
let third = engine(&HostEnv::at_root(&dir));
assert!(
Arc::ptr_eq(&first, &second) && Arc::ptr_eq(&second, &third),
"同一数据根的多次请求必须是同一个引擎"
);
assert_eq!(shared::constructions(&cache_root), 1, "只该构造一次引擎");
std::fs::remove_dir_all(&dir).ok();
}
/// 不同数据根**不共享**引擎(缓存/健康度串味比多建一个引擎严重得多)。
#[test]
fn different_data_roots_get_their_own_engine() {
let dir_a = root("root-a");
let dir_b = root("root-b");
let env_a = HostEnv::at_root(&dir_a);
let env_b = HostEnv::at_root(&dir_b);
let engine_a = engine(&env_a);
let engine_b = engine(&env_b);
assert!(!Arc::ptr_eq(&engine_a, &engine_b));
assert_eq!(shared::constructions(env_a.layout.cache()), 1);
assert_eq!(shared::constructions(env_b.layout.cache()), 1);
std::fs::remove_dir_all(&dir_a).ok();
std::fs::remove_dir_all(&dir_b).ok();
}
/// `forget`(「重新检测地区」)之后下一次请求拿到新引擎。
#[test]
fn forget_makes_the_next_request_rebuild() {
let dir = root("forget");
let env = HostEnv::at_root(&dir);
let before = engine(&env);
forget(&env);
let after = engine(&env);
assert!(!Arc::ptr_eq(&before, &after));
assert_eq!(shared::constructions(env.layout.cache()), 2);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn java_merge_priority_reads_settings() {
let dir = root("priority");
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.config()).expect("config");
std::fs::write(
env.launcher_config(),
r#"{"java":{"vendorPriority":["zulu"]}}"#,
)
.expect("写配置");
assert_eq!(java_priority(&env), vec![JdkVendor::Zulu]);
std::fs::remove_dir_all(&dir).ok();
}
}