From b31ca636d1197f073a87caa57a3603f25e6bc0b9 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 09:33:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(m203):=20CLI=20=E4=B8=89=E6=9D=A1=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E5=91=BD=E4=BB=A4=E6=94=B9=E5=90=83=20download.*=20?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=EF=BC=88=E5=B9=B6=E5=8F=91/=E9=87=8D?= =?UTF-8?q?=E8=AF=95/gh-proxy=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ull install` / `ull java` / `ull pack` 此前各自 `DownloadEngine::new(EngineConfig::default(), …)`: 用户在设置里配的 `download.concurrency` / `retry_limit` / `gh_proxy` 一概不生效;M193 把出厂并发 从 64 改成 8 之后,它们还静默跟着降了。现在三处都经 `crates/cli/src/download.rs::engine(ctx)`: - core:`shared/tuned.rs` 新增 `engine_with_tuning(cache_root, tune)` = `set_tuning` + `engine_for`, 由 `shared.rs` 重导出;`engine_for` 的签名与行为一字未改(presenter 的两步语义原样保留)。 放子模块是为了不动 `engine.rs`(那是 400 行零余量),`shared.rs` 仍 398 行。 - CLI:`download::tuning(ctx)` 用 `SettingStore::load(ctx.launcher_config(), launcher_schema())` 读 三个键(与 `ull config set` 同一份文件),默认值/夹取/回落在 `DownloadTuning::from_settings`; 键缺失或坏值静默回落出厂值,`engine()` 没有错误通道,安装不会因此中断。 - 顺带:`ull update` 复用 `install::build_engine`,也随之吃到设置。 - 测试:`crates/cli/src/download/tests.rs` 三条命令各一条(读 `config().max_concurrency` 与 `concurrency_probe()` 的真实值)+ 一条「缺失/越界/负数/类型不对 ⇒ 静默回落出厂值」; `shared/tuned.rs` 两条守住「灌调优与取引擎是一步」。变异实测:pack 换回裸 `EngineConfig::default()` ⇒ 只有 pack 那条红(left 8 / right 2)。 --- crates/cli/src/commands/install/mod.rs | 32 +--- crates/cli/src/commands/java.rs | 29 +--- crates/cli/src/commands/pack.rs | 29 +--- crates/cli/src/commands/update.rs | 3 +- crates/cli/src/download.rs | 56 +++++++ crates/cli/src/download/tests.rs | 203 +++++++++++++++++++++++ crates/cli/src/lib.rs | 2 + crates/core/src/download/shared.rs | 10 +- crates/core/src/download/shared/tuned.rs | 85 ++++++++++ 9 files changed, 368 insertions(+), 81 deletions(-) create mode 100644 crates/cli/src/download.rs create mode 100644 crates/cli/src/download/tests.rs create mode 100644 crates/core/src/download/shared/tuned.rs diff --git a/crates/cli/src/commands/install/mod.rs b/crates/cli/src/commands/install/mod.rs index 2d3553f..16452d3 100644 --- a/crates/cli/src/commands/install/mod.rs +++ b/crates/cli/src/commands/install/mod.rs @@ -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(®ion_path, now).unwrap_or_else(|| { - let profile = decide_from(&detect_signals(), now); - let _ = store_cached(®ion_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 { + crate::download::engine(ctx) } /// 同名但绑定别的版本 → 冲突;同名同版本视为重装(复用实例,允许)。 diff --git a/crates/cli/src/commands/java.rs b/crates/cli/src/commands/java.rs index 1ab3622..0608313 100644 --- a/crates/cli/src/commands/java.rs +++ b/crates/cli/src/commands/java.rs @@ -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 { .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(®ion_path, now).unwrap_or_else(|| { - let profile = decide_from(&detect_signals(), now); - let _ = store_cached(®ion_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 { + crate::download::engine(ctx) } /// JDK 下载/解压错误 → CLI 错误(保留稳定码与提示)。 diff --git a/crates/cli/src/commands/pack.rs b/crates/cli/src/commands/pack.rs index 8045004..68713d1 100644 --- a/crates/cli/src/commands/pack.rs +++ b/crates/cli/src/commands/pack.rs @@ -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(®ion_path, now).unwrap_or_else(|| { - let profile = decide_from(&detect_signals(), now); - let _ = store_cached(®ion_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 { + crate::download::engine(ctx) } #[cfg(test)] diff --git a/crates/cli/src/commands/update.rs b/crates/cli/src/commands/update.rs index fecf33d..830a685 100644 --- a/crates/cli/src/commands/update.rs +++ b/crates/cli/src/commands/update.rs @@ -34,7 +34,6 @@ //! 去掉 `--dest` 会把它真改名换掉(`crates/cli/tests/update_e2e_test.rs` 的注释写了这件事)。 use std::path::PathBuf; -use std::sync::Arc; use serde_json::json; use unlucky_core::update::{default_source, Applied, CheckOutcome, Job, UpdateError, Version}; @@ -57,7 +56,7 @@ fn current_version() -> Result { /// 造一个内核 job(源 = GitHub / `ULL_UPDATE_DIR`,暂存 = 数据根缓存,目标 = 本进程 exe)。 fn job(ctx: &Ctx, dest: Option) -> Job { let engine = super::install::build_engine(ctx); - let job = Job::for_process(default_source(Arc::new(engine)), &ctx.layout().cache()); + let job = Job::for_process(default_source(engine), &ctx.layout().cache()); match dest { Some(dest) => job.with_dest(dest), None => job, diff --git a/crates/cli/src/download.rs b/crates/cli/src/download.rs new file mode 100644 index 0000000..b02d308 --- /dev/null +++ b/crates/cli/src/download.rs @@ -0,0 +1,56 @@ +//! 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 记着这三条 ❌)。 +//! +//! 现在三处一律经 [`engine`] 取引擎(进程内共享,见 `core::download::shared`): +//! +//! ```text +//! <数据根>/config/launcher.json(与 `ull config set` 同一份) → setting store → DownloadTuning +//! (默认值/夹取/回落在 core) +//! → shared::engine_with_tuning(, 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 { + shared::engine_with_tuning(ctx.layout().cache(), tuning(ctx)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/cli/src/download/tests.rs b/crates/cli/src/download/tests.rs new file mode 100644 index 0000000..daca031 --- /dev/null +++ b/crates/cli/src/download/tests.rs @@ -0,0 +1,203 @@ +//! [`super`] 的单测(M203):`launcher.json` 的 `download.*` 真的进到三条命令拿到的引擎里。 +//! +//! 断言读的是**引擎的真实字段**——`config().max_concurrency` 与 `Semaphore` 的 +//! `available_permits()`(`concurrency_probe`)——不是「函数被调用过」:后者在回落写错、 +//! 或者命令又自建了一个裸引擎时照样绿。 + +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, +}; +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` 落在 `/.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`——**与 `ull config set` 的落点是同一份文件**(`ctx.launcher_config()`), +/// 所以这条用例测的就是用户手改/命令改之后 CLI 读到的值。 +fn write_launcher_config(ctx: &Ctx, document: Value) { + let path = ctx.launcher_config(); + std::fs::create_dir_all(path.parent().expect("配置目录")).expect("建配置目录"); + std::fs::write(&path, serde_json::to_vec(&document).expect("序列化")) + .expect("写 launcher.json"); +} + +/// 「引擎真的按这个并发跑」:配置字段 + 闸门许可数,两处都读真实值。 +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", + ); +} + +/// **反向断言**:键缺失 / 越界 / 负数 / 类型不对 ⇒ 回落出厂值,而且回落是**静默的**。 +/// +/// 「静默」在这里是结构性的:`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": "很多" } }), + ), + ] { + write_launcher_config(&ctx, document); + // 先证明这份文件**真的**是坏的(否则「回落」只是键不存在,用例就成了空断言)。 + let store = SettingStore::load(ctx.launcher_config(), 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}:尝试次数也回落出厂值" + ); + } +} diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index cdd8dcf..8b99da0 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -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; diff --git a/crates/core/src/download/shared.rs b/crates/core/src/download/shared.rs index 3f0257a..c1ead3a 100644 --- a/crates/core/src/download/shared.rs +++ b/crates/core/src/download/shared.rs @@ -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>> { static ENGINES: OnceLock>>> = OnceLock::new(); @@ -136,10 +139,9 @@ pub fn constructions(cache_root: impl AsRef) -> 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); diff --git a/crates/core/src/download/shared/tuned.rs b/crates/core/src/download/shared/tuned.rs new file mode 100644 index 0000000..6acfe1a --- /dev/null +++ b/crates/core/src/download/shared/tuned.rs @@ -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, + tune: DownloadTuning, +) -> Arc { + 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(); + } +}