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)未打,见报告。
This commit is contained in:
AskaEth committed 2026-11-08 09:30:05 +08:00
1 parent b4e6fbf8c1
commit 46389d61fd
11 files changed
+486 -48

No files matched your search

+5
View File
@@ -7,6 +7,11 @@
//! | 无自定义 header(Modrinth 的全部请求) | [`DownloadEngine::download_bytes`] | **信号量限流 + 源健康度 + 指数退避 + 分类错误**,全是 core 现成的 |
//! | 带 header(只有 CurseForge 的 `X-API-KEY`) | `engine.client()` 直发 | 引擎的请求体没有 header 字段,只能自己发;代价是这条路径自己带一个 429/5xx 退避 |
//!
//! 两条路径用的是**同一个引擎**,而引擎是进程级共享的(M165:`host::engine::engine` →
//! `core::download::shared::engine_for`,一个数据根一个)。所以 Modrinth 与 CurseForge 的请求、
//! 以及它们与真正的文件下载,共用同一条连接池与同一套限流;`block_on` 也跑在同一个常驻运行时上,
//! 池里的空闲连接不会随着某次调用结束而变成死连接。
//!
//! ## 密钥纪律
//!
//! 带 header 的那条路径**只把 header 值喂给请求**:不进日志、不进错误串。错误串里只有 URL
+13 -3
View File
@@ -96,12 +96,22 @@ fn modrinth_detail(
let core_engine = engine::engine(env);
let transport = EngineTransport::new(&core_engine);
let client = Modrinth::new(&transport);
let project = block_on(client.project(&request.project)).map_err(failure_of)?;
let versions = block_on(client.versions(&VersionQuery {
let query = VersionQuery {
project: request.project.clone(),
game_version: request.game_version.clone(),
loader: request.loader.and_then(loader_facet).map(str::to_string),
}))
};
// 项目详情 + 版本列表是**两次串行 GET**,包在**一次** `block_on` 里(宿主侧少一层阻塞边界,
// 也就少一次运行时进出——M165 登记的「单请求两次 block_on」在这里收掉)。
//
// 这不是嵌套 `block_on`:两段 `await` 依次跑在同一个 future 里,落在同一个常驻运行时
// (`core::download::blocking`)与同一个共享引擎上,连接池/限流照常复用。
// 失败语义不变:哪一步先失败就先返回,与拆成两次调用时的先后顺序一致,分类仍走 `failure_of`。
let (project, versions) = block_on(async {
let project = client.project(&request.project).await?;
let versions = client.versions(&query).await?;
Ok::<_, OnlineError>((project, versions))
})
.map_err(failure_of)?;
Ok(ContentDetail {
project: if project.id.is_empty() {
+92 -29
View File
@@ -1,40 +1,38 @@
//! 下载引擎与 Java 落点的装配(versions / install / java 三个任务共用)。
//!
//! **引擎是进程级的、按数据根共享的**(M165):本模块不再自己 `DownloadEngine::new`,而是向
//! [`unlucky_core::download::shared`] 要那个数据根上已经存在的引擎。于是连接池、信号量限流、
//! 镜像健康度、内容寻址缓存都跨请求累积,而不是每次请求清零(逐项代价与边界见那个模块的文档)。
//!
//! 与 CLI `install::build_engine` 同源:内容寻址缓存落 `.ULL/cache/`,地区判定走
//! 24h 缓存 + 环境信号(**不做网络探测**,与 D7 的「不探测时」约定一致)。
//! 24h 缓存 + 环境信号(**不做网络探测**,与 D7 的「不探测时」约定一致);区别只在生命周期
//! ——CLI 一条命令一个进程,建一次就够;GUI 进程要在成百上千次请求之间复用同一份。
use std::path::PathBuf;
use std::sync::Arc;
use unlucky_core::download::cache::now_ms;
use unlucky_core::download::region::{decide_from, load_cached, store_cached};
use unlucky_core::download::region_system::detect_signals;
use unlucky_core::download::{
ContentCache, DownloadEngine, EngineConfig, HealthRegistry, MirrorTable,
};
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;
/// 建下载引擎(缓存/健康度/地区判定都在数据根里)。
pub fn engine(env: &HostEnv) -> DownloadEngine {
let cache_root = env.layout.cache();
let now = now_ms();
let region_path = cache_root.join("region.json");
let region = load_cached(&region_path, now).unwrap_or_else(|| {
let profile = decide_from(&detect_signals(), now);
let _ = store_cached(&region_path, &profile);
profile
});
DownloadEngine::new(
EngineConfig::default(),
Arc::new(MirrorTable::builtin()),
Arc::new(region),
Arc::new(HealthRegistry::open_in(&cache_root, now)),
Arc::new(ContentCache::open(&cache_root)),
)
/// 取该数据根上**共享**的下载引擎(缓存/健康度/地区判定都在数据根里)。
///
/// 同一个数据根无论被请求多少次,都是同一个引擎;不同数据根各一份(缓存/健康度不串味)。
#[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());
}
/// 是否国内网络(决定默认发行版顺序与镜像优先级)。
@@ -96,12 +94,23 @@ pub fn java_scan_roots(env: &HostEnv) -> Vec<PathBuf> {
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 = std::env::temp_dir().join(format!("ull-host-engine-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let dir = root("cache");
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.cache()).expect("cache");
let engine = engine(&env);
assert_eq!(engine.config().max_concurrency, 64);
// 地区判定不联网:只读缓存/信号
@@ -109,10 +118,64 @@ mod tests {
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 = std::env::temp_dir().join(format!("ull-host-engine-p-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let dir = root("priority");
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.config()).expect("config");
std::fs::write(
@@ -36,7 +36,7 @@ pub(super) type StepView = (usize, String, Vec<String>, usize);
pub(super) struct Harness {
dir: PathBuf,
pub(super) env: HostEnv,
pub(super) engine: DownloadEngine,
pub(super) engine: Arc<DownloadEngine>,
tx: JobSender,
rx: Receiver<HostTask>,
pub(super) meter: Arc<Mutex<Meter>>,
+4
View File
@@ -371,11 +371,15 @@ fn parse_value(key: &str, value: &str) -> Result<Value, String> {
}
/// 清掉 24h 地区缓存(下次建引擎重新判定)。
///
/// M165 起引擎是进程级共享的:光删 `region.json` 不够(下次请求根本不会重建引擎),
/// 必须同时让共享登记处淘汰这一份,否则「重新检测地区」这句话在本次运行里不生效。
fn detect_region(env: &HostEnv) -> Result<(), String> {
let path = env.layout.cache().join("region.json");
if path.is_file() {
std::fs::remove_file(&path).map_err(|error| format!("清地区缓存失败:{error}"))?;
}
super::engine::forget(env);
Ok(())
}
+1 -1
View File
@@ -130,7 +130,7 @@ impl Factory {
#[must_use]
pub fn for_env(env: &HostEnv) -> Self {
Self::Default {
engine: Arc::new(engine::engine(env)),
engine: engine::engine(env),
target: Target::current(),
staging: staging_dir(&env.layout.cache()),
dest: unlucky_core::update::process_dest(),