Merge branch 'feat/m203-clituning-cli-download-tuning-from'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s

This commit is contained in:
AskaEth committed 2026-11-09 10:24:51 +08:00
commit 0cc1a35420
10 files changed
+449 -86

No files matched your search

+5 -27
View File
@@ -19,12 +19,7 @@ mod vanilla;
use std::sync::Arc;
use unlucky_core::addon::loader::LoaderKind;
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, DownloadErrorKind, EngineConfig, HealthRegistry, MirrorTable,
};
use unlucky_core::download::{DownloadEngine, DownloadErrorKind};
use unlucky_core::game::install::{InstallError, InstallReport, Registration};
use crate::cli::InstallArgs;
@@ -78,27 +73,10 @@ pub(super) fn register_instance(
.map_err(|error| CliError::user("instance_register", format!("登记实例失败:{error}")))
}
/// 建下载引擎:内容寻址缓存落 `.ULL/cache/`,地区判定走 24h 缓存 + 环境信号。
///
/// 地区判定**不做网络探测**(P2 首版;探测开关属设置层接线),
/// 因此中文环境默认镜像优先、其它环境直连——与 D7 的"不探测时"约定一致。
pub(super) fn build_engine(ctx: &Ctx) -> DownloadEngine {
let layout = ctx.layout();
let cache_root = 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)),
)
/// 建下载引擎:内容寻址缓存落 `.ULL/cache/`,装配参数来自设置(并发 / 重试 / gh-proxy,
/// 读数口径见 [`crate::download`])——不再用裸 `EngineConfig::default()`(M203)。
pub(crate) fn build_engine(ctx: &Ctx) -> Arc<DownloadEngine> {
crate::download::engine(ctx)
}
/// 同名但绑定别的版本 → 冲突;同名同版本视为重装(复用实例,允许)。
+5 -24
View File
@@ -17,12 +17,7 @@ use std::sync::Arc;
use serde_json::{json, Value};
use unlucky_core::download::blocking::block_on;
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, RegionProfile,
};
use unlucky_core::download::{DownloadEngine, RegionProfile};
use unlucky_core::game::java::{
self, location, vendor, InstallRequest, JavaOrigin, JavaRuntime, JdkVendor, LocationMode,
DEFAULT_MAJOR,
@@ -312,24 +307,10 @@ fn install_root_of(runtime: &JavaRuntime) -> Option<String> {
.map(|dir| dir.display().to_string())
}
/// 建下载引擎:内容寻址缓存落 `.ULL/cache/`,地区判定走 24h 缓存 + 环境信号。
fn build_engine(ctx: &Ctx) -> DownloadEngine {
let layout = ctx.layout();
let cache_root = 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)),
)
/// 建下载引擎:装配参数来自设置(并发 / 重试 / gh-proxy,读数口径见 [`crate::download`]),
/// 引擎本体与 `install` / `pack` 共用同一个(core 按缓存根共享)。
pub(crate) fn build_engine(ctx: &Ctx) -> Arc<DownloadEngine> {
crate::download::engine(ctx)
}
/// JDK 下载/解压错误 → CLI 错误(保留稳定码与提示)。
+5 -24
View File
@@ -30,12 +30,7 @@ use unlucky_core::addon::import::{
ImportReport, ImportRequest, ImportStatus,
};
use unlucky_core::download::blocking::block_on;
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::DownloadEngine;
use crate::cli::PackImportArgs;
use crate::context::Ctx;
@@ -286,24 +281,10 @@ fn count_action(report: &ImportReport, action: FileAction) -> usize {
.count()
}
/// 与 `install` 共用同一套引擎构造(镜像 / 地区 / 健康度 / 内容缓存)。
fn build_engine(ctx: &Ctx) -> DownloadEngine {
let layout = ctx.layout();
let cache_root = 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)),
)
/// 与 `install` / `java` 共用同一套引擎装配(镜像 / 地区 / 健康度 / 内容缓存),
/// 参数来自设置:读数口径见 [`crate::download`]。
pub(crate) fn build_engine(ctx: &Ctx) -> Arc<DownloadEngine> {
crate::download::engine(ctx)
}
#[cfg(test)]
+13 -2
View File
@@ -37,6 +37,7 @@ use std::path::PathBuf;
use std::sync::Arc;
use serde_json::json;
use unlucky_core::download::DownloadEngine;
use unlucky_core::update::{default_source, Applied, CheckOutcome, Job, UpdateError, Version};
use crate::cli::{UpdateApplyArgs, UpdateCheckArgs};
@@ -54,10 +55,20 @@ fn current_version() -> Result<Version, CliError> {
.map_err(|error| CliError::internal("bad_version", format!("编译期版本号不合法:{error}")))
}
/// 自更新的下载引擎:与 `install` / `java` / `pack` 同一条装配(参数来自
/// [`crate::download`],设置读数与落点在那边)。
///
/// 自更新只下**一个** artifact(`update::github` 的 `fetch_artifact` → 单次 `engine.download`),
/// 所以并发的差别在这条路径上不可观测;真正随设置变的是**每源重试次数**与**gh-proxy 前缀**——
/// 那正是「设置说真话」的两维。单独留这个接缝是为了让用例能逐命令打点(变异时只有它红)。
pub(crate) fn engine(ctx: &Ctx) -> Arc<DownloadEngine> {
super::install::build_engine(ctx)
}
/// 造一个内核 job(源 = GitHub / `ULL_UPDATE_DIR`,暂存 = 数据根缓存,目标 = 本进程 exe)。
fn job(ctx: &Ctx, dest: Option<PathBuf>) -> Job {
let engine = super::install::build_engine(ctx);
let job = Job::for_process(default_source(Arc::new(engine)), &ctx.layout().cache());
let engine = engine(ctx);
let job = Job::for_process(default_source(engine), &ctx.layout().cache());
match dest {
Some(dest) => job.with_dest(dest),
None => job,
+57
View File
@@ -0,0 +1,57 @@
//! CLI 侧的下载引擎:把 CLI 自己的 `launcher.json` 接到 core 的下载调优上(M203)。
//!
//! `install` / `java` / `pack` 三条命令此前各自 `DownloadEngine::new(EngineConfig::default(), …)`:
//! 用户在设置里配的 `download.concurrency` / `download.retry_limit` / `download.gh_proxy` 一概不生效。
//! M193 把出厂并发从 64 改成 8 之后,它们还**静默**跟着降了——「设置项说假话」的同期病灶
//! (`docs/download-settings.md` §3.1 记着这三条 ❌)。第四条路径 `update`(自更新)复用 `install`
//! 的装配,也随之吃设置;它只下一个 artifact,所以并发在那条路径上不可观测。
//!
//! 现在四条一律经 [`engine`] 取引擎(进程内共享,见 `core::download::shared`):
//!
//! ```text
//! <数据根>/config/launcher.json(与 `ull config set` 同一份) → setting store → DownloadTuning
//! (默认值/夹取/回落在 core)
//! → shared::engine_with_tuning(<cache 根>, tuning) → 引擎的 Semaphore / attempts_for()
//! ```
//!
//! **回落是静默的**:`SettingStore::load` 永不失败(坏值只进 `problems()`);类型不对 → `None`,
//! 越界 → `DownloadTuning::from_settings` 的区间过滤。两种情况都落成出厂值,**不报错、不中断安装**。
use std::sync::Arc;
use unlucky_core::download::config::{
DownloadTuning, KEY_CONCURRENCY, KEY_GH_PROXY, KEY_RETRY_LIMIT,
};
use unlucky_core::download::{shared, DownloadEngine};
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use crate::context::Ctx;
/// 从 CLI 自己的 setting store 读下载调优(`launcher.json` 的三个 `download.*` 键)。
///
/// 读的是 `ctx.launcher_config()`——与 `ull config set download.concurrency` 的落点同一份文件
/// (设置键的落点分流见 [`Ctx::setting_path_for`])。gh-proxy 空串 = 跟随内置默认,**不是**关。
#[must_use]
pub(crate) fn tuning(ctx: &Ctx) -> DownloadTuning {
let store = SettingStore::load(ctx.launcher_config(), launcher_schema());
let doc = store.doc();
let gh_proxy = doc
.effective_str(KEY_GH_PROXY)
.unwrap_or_default()
.to_string();
DownloadTuning::from_settings(
doc.effective_i64(KEY_CONCURRENCY),
doc.effective_i64(KEY_RETRY_LIMIT),
Some(gh_proxy.as_str()),
)
}
/// 建该数据根的下载引擎:参数来自 [`tuning`],引擎本体由 core 按缓存根共享。
#[must_use]
pub(crate) fn engine(ctx: &Ctx) -> Arc<DownloadEngine> {
shared::engine_with_tuning(ctx.layout().cache(), tuning(ctx))
}
#[cfg(test)]
mod tests;
+266
View File
@@ -0,0 +1,266 @@
//! [`super`] 的单测(M203):`launcher.json` 的 `download.*` 真的进到四条命令拿到的引擎里
//! (`install` / `java` / `pack` / `update`——`update` 复用 `install` 的装配,自更新只下一个
//! artifact,所以并发在那条路径上不可观测,随设置变的是每源重试次数与 gh-proxy 前缀)。
//!
//! 断言读的是**引擎的真实字段**——`config().max_concurrency` 与 `Semaphore` 的
//! `available_permits()`(`concurrency_probe`)——不是「函数被调用过」:后者在回落写错、
//! 或者命令又自建了一个裸引擎时照样绿。gh-proxy 那一条断言的是**候选链**(真行为),不是配置值。
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::{json, Value};
use unlucky_core::download::config::{
DownloadTuning, DEFAULT_MAX_ATTEMPTS_PER_SOURCE, DEFAULT_MAX_CONCURRENCY, KEY_CONCURRENCY,
};
use unlucky_core::download::types::{DownloadRequest, ResourceKind};
use unlucky_core::download::DownloadEngine;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use crate::cli::Globals;
use crate::context::Ctx;
/// 一次性临时目录(cli 没有 dev-dependency 的 tempfile;与 `commands/config/tests.rs` 同一手法)。
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"ull-cli-download-{tag}-{}-{seq}-{nanos}",
std::process::id()
));
std::fs::create_dir_all(&path).expect("建临时目录");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// 数据根钉在临时目录里:`launcher.json` 落在 `<dir>/.ULL/config/launcher.json`。
fn ctx_at(dir: &TempDir) -> Ctx {
let globals = Globals {
data_dir: Some(dir.path().to_path_buf()),
..Globals::default()
};
Ctx::resolve(&globals).expect("解析数据根")
}
/// 直接写 `launcher.json`——落点用 [`Ctx::setting_path_for`](`ull config set` 用的那个表达式),
/// **不是**实现读侧用的 `ctx.launcher_config()`。
///
/// 两个表达式今天指向同一份文件(`download.*` 不是数据根键 ⇒ 回落 `launcher_config()`),但写侧
/// 走「用户改配置那条路」的 API,实现哪天改读别的文件时这条用例会红,而不是跟着一起漂移。
fn write_launcher_config(ctx: &Ctx, document: Value) -> PathBuf {
let path = ctx.setting_path_for(KEY_CONCURRENCY);
std::fs::create_dir_all(path.parent().expect("配置目录")).expect("建配置目录");
std::fs::write(&path, serde_json::to_vec(&document).expect("序列化"))
.expect("写 launcher.json");
path
}
/// 「引擎真的按这个并发跑」:配置字段 + 闸门许可数,两处都读真实值。
fn assert_concurrency(engine: &DownloadEngine, expected: usize, case: &str) {
assert_eq!(
engine.config().max_concurrency,
expected,
"{case}:`EngineConfig` 里的并发"
);
assert_eq!(
engine.concurrency_probe(),
expected,
"{case}:`Semaphore` 真实许可数"
);
}
/// **`install` 吃设置**:`download.concurrency` 改了就变;同一次进程里再改一次也立刻变(不是只读一次)。
///
/// 抓得住的变异:`commands::install::build_engine` 换回裸 `EngineConfig::default()` ⇒ 3 / 5 两处红。
/// 抓不住的:真实下载时「只有 3 条连接在飞」的时序(离线验不了,见报告「没验的部分」)。
#[test]
fn install_engine_takes_the_concurrency_from_the_setting_store() {
let dir = TempDir::new("install");
let ctx = ctx_at(&dir);
// 没有 launcher.json = 没配过 ⇒ 出厂值(这也是「回落」的第一档)。
assert_concurrency(
&crate::commands::install::build_engine(&ctx),
DEFAULT_MAX_CONCURRENCY,
"未配置",
);
write_launcher_config(&ctx, json!({ "download": { "concurrency": 3 } }));
assert_concurrency(
&crate::commands::install::build_engine(&ctx),
3,
"concurrency=3",
);
write_launcher_config(&ctx, json!({ "download": { "concurrency": 5 } }));
assert_concurrency(
&crate::commands::install::build_engine(&ctx),
5,
"concurrency=5",
);
}
/// **`java` 吃设置**:并发与每个源的尝试次数都进引擎。
///
/// 抓得住的变异:`commands::java::build_engine` 换回裸 `EngineConfig::default()` ⇒ 探针停在 8、次数停在 3,红。
#[test]
fn java_engine_takes_the_concurrency_and_retry_from_the_setting_store() {
let dir = TempDir::new("java");
let ctx = ctx_at(&dir);
write_launcher_config(
&ctx,
json!({ "download": { "concurrency": 3, "retry_limit": 4 } }),
);
let engine = crate::commands::java::build_engine(&ctx);
assert_concurrency(&engine, 3, "concurrency=3");
assert_eq!(
engine.config().max_attempts_per_source,
4,
"retry_limit=4 要真的落到引擎上(出厂是 3)"
);
}
/// **`pack` 吃设置**(四条命令各有一条独立断言:把任意一处改回裸引擎,只有那一条红)。
///
/// 抓得住的变异:`commands::pack::build_engine` 换回裸 `EngineConfig::default()` ⇒ 2 处红。
#[test]
fn pack_engine_takes_the_concurrency_from_the_setting_store() {
let dir = TempDir::new("pack");
let ctx = ctx_at(&dir);
write_launcher_config(&ctx, json!({ "download": { "concurrency": 2 } }));
assert_concurrency(
&crate::commands::pack::build_engine(&ctx),
2,
"concurrency=2",
);
}
/// **`update` 吃设置**(第 4 条路径,`commands/update.rs::engine`):自更新也只拿出厂值是不行的。
///
/// 这条路径上「并发」实际不可观测(自更新只下**一个** artifact,闸门开 1 还是 8 都只有一条连接),
/// 但引擎参数是同一处装配出来的,用并发打点最省事;真正随设置变的是每源重试次数与 gh-proxy 前缀。
/// 抓得住的变异:`commands::update::engine` 换回裸 `EngineConfig::default()` ⇒ 4 ≠ 8 处红,另五条绿。
#[test]
fn update_engine_takes_the_concurrency_from_the_setting_store() {
let dir = TempDir::new("update");
let ctx = ctx_at(&dir);
write_launcher_config(&ctx, json!({ "download": { "concurrency": 4 } }));
assert_concurrency(&crate::commands::update::engine(&ctx), 4, "concurrency=4");
}
/// **`gh_proxy` 的 CLI 侧闭环**:手写 `"off"` ⇒ 候选链里真的没有 gh-proxy(读的是 `plan()`,不是配置值)。
///
/// 两段式:先在镜像优先的地区档案下确认**出厂**(空串 = 跟随内置)确实生成 gh-proxy 候选,再把它
/// 改成 `off` 确认候选消失——只断言后者的话,「本来就没有候选」会让用例白绿。
/// 抓得住的变异:`download::tuning` 不读 `KEY_GH_PROXY`(传 `None`)⇒ 第二段仍有 gh-proxy 候选,红;
/// 把空串当成关(`gh_proxy_override("")` 返回 `Off`)⇒ 第一段没有候选,红(回落用例的
/// `tuning == default` 同时红——两处都该红,实测如此)。
/// 抓不住的:真实网络下这个前缀能不能通(离线只验候选链的**生成**)。
#[test]
fn the_gh_proxy_setting_reaches_the_candidate_chain() {
use unlucky_core::download::cache::now_ms;
use unlucky_core::download::region::{store_cached, RegionProfile};
let dir = TempDir::new("ghproxy");
let ctx = ctx_at(&dir);
// gh-proxy 只在「镜像优先」的地区档案下生成候选:先把该数据根的 region.json 钉住。
let cache_root = ctx.layout().cache();
store_cached(
&cache_root.join("region.json"),
&RegionProfile::mirror_first(now_ms()),
)
.expect("地区档案");
let url = "https://github.com/o/r/releases/download/v1/a.jar";
let chain = |ctx: &Ctx| -> Vec<String> {
let request =
DownloadRequest::new(ResourceKind::Other, url, ctx.layout().cache().join("a.jar"));
crate::download::engine(ctx)
.plan(&request, 0)
.into_iter()
.map(|candidate| candidate.url)
.collect()
};
let has_gh_proxy = |chain: &[String]| chain.iter().any(|url| url.contains("gh-proxy"));
// 出厂(未配置):空串 = 跟随内置默认 ⇒ 有 gh-proxy 候选。
assert!(has_gh_proxy(&chain(&ctx)), "出厂该套内置前缀");
// 写 off:候选里不该再有 gh-proxy(其余候选仍在)。
write_launcher_config(&ctx, json!({ "download": { "gh_proxy": "off" } }));
let off = chain(&ctx);
assert!(!has_gh_proxy(&off), "off 档还有 gh-proxy 候选:{off:?}");
assert!(!off.is_empty(), "关掉加速不等于没有候选:{off:?}");
}
/// **反向断言**:键缺失 / 越界 / 负数 / 类型不对 ⇒ 回落出厂值,而且回落是**静默的**。
///
/// 「静默」在这里是结构性的:`crate::download::engine` 的返回类型没有错误通道,坏设置只进
/// `SettingStore::problems()`,调用方(安装流程)连一个 `?` 都不用加——`ull install` 不会因为一条
/// 写坏的设置而中断,只是按出厂值跑。
///
/// 抓得住的变异:删掉 `DownloadTuning::from_settings` 的区间过滤 ⇒ 9_999 那条把许可数变成 64,红;
/// 把 CLI 的读数改成 `expect`/报错 ⇒ 坏值文件这几档 panic 或编译不过,红。
/// 抓不住的:`problems()` 的内容与措辞(那是 core 的 `setting` 模块,本用例只借它证明文件真的坏)。
#[test]
fn missing_or_illegal_values_fall_back_to_the_factory_defaults_silently() {
let dir = TempDir::new("fallback");
let ctx = ctx_at(&dir);
assert_eq!(
super::tuning(&ctx),
DownloadTuning::default(),
"没有 launcher.json 就该是出厂调优"
);
for (case, document) in [
(
"越界",
json!({ "download": { "concurrency": 9_999, "retry_limit": 99 } }),
),
(
"负数",
json!({ "download": { "concurrency": -4, "retry_limit": -1 } }),
),
(
"类型不对",
json!({ "download": { "concurrency": "八", "retry_limit": "很多" } }),
),
] {
let written = write_launcher_config(&ctx, document);
// 先证明这份文件**真的**是坏的(否则「回落」只是键不存在,用例就成了空断言)。
let store = SettingStore::load(&written, launcher_schema());
assert!(
!store.problems().is_empty(),
"{case}:这份文件该被判为有问题"
);
assert_eq!(
super::tuning(&ctx),
DownloadTuning::default(),
"{case}:坏值必须回落出厂调优"
);
let engine = crate::commands::install::build_engine(&ctx);
assert_concurrency(&engine, DEFAULT_MAX_CONCURRENCY, case);
assert_eq!(
engine.config().max_attempts_per_source,
DEFAULT_MAX_ATTEMPTS_PER_SOURCE,
"{case}:尝试次数也回落出厂值"
);
}
}
+2
View File
@@ -6,6 +6,7 @@
//! 分层:
//! - [`cli`]:手写参数解析(不引 clap)+ 命令模型 + 帮助文案;
//! - [`commands`]:每个子命令一个函数,输入 [`context::Ctx`],输出 [`output::Report`];
//! - [`download`]:CLI 侧的下载引擎(设置 store → core 调优,`install`/`java`/`pack` 共用);
//! - [`context`]:数据根解析(三模式 / `--data-dir` 覆盖)与目录视图;
//! - [`os`]:系统摘要与 D19/D21 支持级别(事实来自 core::platform::os_info,这里只做呈现);
//! - [`output`]:人类可读 + `--json` 双格式输出;
@@ -19,6 +20,7 @@
pub mod cli;
pub mod commands;
pub mod context;
pub mod download;
pub mod error;
pub mod os;
pub mod output;
+5 -5
View File
@@ -33,11 +33,11 @@ pub const MAX_ATTEMPTS_PER_SOURCE: u32 = 10;
pub const KEY_CONCURRENCY: &str = "download.concurrency";
/// 见 [`KEY_CONCURRENCY`]。
pub const KEY_RETRY_LIMIT: &str = "download.retry_limit";
/// gh-proxy 前缀覆盖键(三态语义见 [`gh_proxy_override`])。
///
/// **暂未登记**:由 M197 在 `setting::keys::proxy` 登记,尚未合入 dev;宿主读它得到 `None` ⇒
/// [`GhProxyOverride::Builtin`](行为正确)。**M197 合入后这条断言应升级为 schema 断言**——现在
/// 只断言常量本身,别把这条弱断言当成可以随手删的。
/// gh-proxy 前缀覆盖键(三态语义见 [`gh_proxy_override`])。**已登记**:`setting::keys::proxy` 的
/// `specs()` 里有它(文本 / 出厂空串),presenter 与 CLI 的宿主都按这个常量读,读不到 ⇒
/// [`GhProxyOverride::Builtin`]。**弱在哪(别当死键删)**:本文件没有断言把它与 schema 登记绑起来,
/// 常量值被改名或写错时这里不红,行为只会在宿主侧静默退化成 `Builtin`(用户填的 `off` 被忽略);
/// 拦住行为退化的是 `empty_gh_proxy_means_follow_builtin_not_off` 与 `shared` 的「调优 → 候选链」。
pub const KEY_GH_PROXY: &str = "download.gh_proxy";
/// 引擎配置。
+6 -4
View File
@@ -55,6 +55,9 @@ use super::mirrors::MirrorTable;
use super::region::{decide_from, load_cached, store_cached};
use super::region_system::detect_signals;
mod tuned;
pub use tuned::engine_with_tuning;
/// 登记处:缓存根 → 引擎。
fn registry() -> &'static Mutex<HashMap<PathBuf, Arc<DownloadEngine>>> {
static ENGINES: OnceLock<Mutex<HashMap<PathBuf, Arc<DownloadEngine>>>> = OnceLock::new();
@@ -136,10 +139,9 @@ pub fn constructions(cache_root: impl AsRef<Path>) -> usize {
.unwrap_or(0)
}
/// 真实构造(**presenter 侧**唯一一处 `DownloadEngine::new` 的宿主侧装配;CLI 还有三处自建,
/// 见 `docs/download-settings.md` §3 的「哪些路径吃这套设置」)。装配参数从该数据根的 [`wanted`] 取,
/// 不用裸 `EngineConfig::default()`(M193):并发 → `Semaphore` 容量、尝试次数 → `attempts_for()`
/// 上限、gh_proxy → 地区档案(`expand()` 只认档案里那个前缀)。值由宿主经 [`set_tuning`] 灌入。
/// 真实构造(**presenter 侧**唯一一处 `DownloadEngine::new` 的宿主装配;CLI 走 [`engine_with_tuning`])。
/// 装配参数从该数据根的 [`wanted`] 取,不用裸 `EngineConfig::default()`(M193):并发 → `Semaphore`
/// 容量、尝试次数 → `attempts_for()` 上限、gh_proxy → 地区档案(`expand()` 只认档案里那个前缀)。
fn build(cache_root: &Path) -> DownloadEngine {
let now = now_ms();
let tune = wanted(cache_root);
+85
View File
@@ -0,0 +1,85 @@
//! 带调优取引擎:把「灌设置」与「取引擎」绑成一次调用(M203)。
//!
//! [`super::set_tuning`] 与 [`super::engine_for`] 是两处显式同步点,宿主少调前一步就会**静默**
//! 拿到出厂参数的引擎——CLI 的 `install` / `java` / `pack` 此前各自 `DownloadEngine::new(
//! EngineConfig::default(), …)`,用户在设置里配的并发 / 重试 / gh-proxy 一概不生效。
//! 把它们绑成一次调用,漏调就成了类型错误而不是「跑起来但用出厂值」。
//!
//! 现有 [`super::engine_for`] 的签名与行为**不动**(presenter 走 `set_tuning` + `engine_for`
//! 两步,语义相同,见 `presenter::host::settings::download::apply_tuning`)。
use std::path::Path;
use std::sync::Arc;
use super::super::config::DownloadTuning;
use super::super::engine::DownloadEngine;
use super::{engine_for, set_tuning};
/// 先灌该数据根的调优,再取它的共享引擎(调优没变时复用的是同一个引擎)。
///
/// 幂等:值没变时 `set_tuning` 一行不动,所以「每次取引擎前都调」不会白扔连接池。
#[must_use]
pub fn engine_with_tuning(
cache_root: impl AsRef<Path>,
tune: DownloadTuning,
) -> Arc<DownloadEngine> {
let root = cache_root.as_ref();
let _ = set_tuning(root, tune);
engine_for(root)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::download::config::{DEFAULT_MAX_ATTEMPTS_PER_SOURCE, DEFAULT_MAX_CONCURRENCY};
use std::path::PathBuf;
fn root(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ull-shared-tuned-{tag}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建缓存根");
dir
}
/// **调优与取引擎必须是一步**:调 `engine_with_tuning` 就该拿到吃这套参数的引擎。
///
/// 抓得住的变异:把 `engine_with_tuning` 里的 `set_tuning(...)` 删掉(只留 `engine_for`)⇒
/// 探针停在出厂 8,红。抓不住的:设置键 → 调优那一段(在 CLI 侧,`cli/src/download/tests.rs`)。
#[test]
fn the_tuning_reaches_the_engine_it_returns() {
let dir = root("reach");
let engine =
engine_with_tuning(&dir, DownloadTuning::from_settings(Some(3), Some(2), None));
assert_eq!(engine.config().max_concurrency, 3);
assert_eq!(engine.concurrency_probe(), 3, "闸门真开了 3 张许可");
assert_eq!(engine.config().max_attempts_per_source, 2);
// 出厂调优与「没灌过」等价:不该因为这次调用就把引擎参数写坏。
let plain = engine_with_tuning(&dir, DownloadTuning::default());
assert_eq!(plain.config().max_concurrency, DEFAULT_MAX_CONCURRENCY);
assert_eq!(
plain.config().max_attempts_per_source,
DEFAULT_MAX_ATTEMPTS_PER_SOURCE
);
std::fs::remove_dir_all(&dir).ok();
}
/// 调优变了就要换引擎(`set_tuning` 的那半个承诺在这里也成立)。
///
/// 抓得住的变异:`engine_with_tuning` 只 `engine_for` 不看调优的变更 ⇒ 交回旧引擎(`ptr_eq` 红)。
#[test]
fn a_changed_tuning_yields_a_rebuilt_engine() {
let dir = root("rebuild");
let first = engine_with_tuning(&dir, DownloadTuning::from_settings(Some(2), None, None));
let same = engine_with_tuning(&dir, DownloadTuning::from_settings(Some(2), None, None));
assert!(Arc::ptr_eq(&first, &same), "同值不该重建");
let next = engine_with_tuning(&dir, DownloadTuning::from_settings(Some(5), None, None));
assert!(!Arc::ptr_eq(&first, &next), "改了并发必须重建");
assert_eq!(next.concurrency_probe(), 5);
std::fs::remove_dir_all(&dir).ok();
}
}