From 7995f62908d5fba08b237e01055eb90f3f523086 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 10:06:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(m203):=20=E7=BB=99=E7=AC=AC=204=20=E6=9D=A1?= =?UTF-8?q?=E5=90=83=E8=AE=BE=E7=BD=AE=E7=9A=84=E8=B7=AF=E5=BE=84=EF=BC=88?= =?UTF-8?q?ull=20update=EF=BC=89=E8=A1=A5=E6=89=93=E7=82=B9=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E6=B8=85=E6=8E=89=20KEY=5FGH=5FPROXY=20=E7=9A=84?= =?UTF-8?q?=E8=85=90=E5=8C=96=20doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 r1 P2-1(p2-1items / fix-then-merge):`ull update` 复用 `install::build_engine` 后成了第 4 条 吃设置的路径,但没有任何用例钉它——把 update 换回裸引擎,四条 download::tests 全绿。按 pack 的完全 同形接缝补上: - `commands/update.rs` 新增 `pub(crate) fn engine(ctx)`(`job()` 改调它),doc 写明这条路径上并发 不可观测(自更新只下一个 artifact),随设置变的是每源重试次数与 gh-proxy 前缀; - `download/tests.rs` 新增 `update_engine_takes_the_concurrency_from_the_setting_store`: 写 concurrency=4 ⇒ 断言 `config().max_concurrency` 与 `concurrency_probe()` 都是 4。 顺带收掉复审的三条观察: 1. 新增 `the_gh_proxy_setting_reaches_the_candidate_chain`:镜像优先地区档案下,出厂(空串 = 跟随 内置)确实生成 gh-proxy 候选;手写 `gh_proxy: "off"` 后候选里没有 gh-proxy(读的是 `plan()` 的 候选链,不是配置值;两段式,避免「本来就没有候选」的白绿)。 2. 测试写侧改用 `ctx.setting_path_for(KEY_CONCURRENCY)`(`ull config set` 用的那个表达式),不再与 实现读侧的 `launcher_config()` 共用同一个表达式——实现改读别的文件时用例会红而不是一起漂移。 3. `core/download/config.rs` 的 `KEY_GH_PROXY` doc 已腐化(写「暂未登记、M197 才登记、别删这条弱 断言」,而该键在 main 上就已登记、本文件根本没有针对该常量的断言):改成事实,并保留「弱在哪」 ——常量值与 schema 登记没被断言绑在一起,改名只会让宿主侧静默退化成 Builtin。 变异验证(只改实现侧,逐次还原): - update::engine 换回裸 `EngineConfig::default()` ⇒ 只有 update 那条红(left 8 / right 4),另五条绿; - `download::tuning` 不读 `KEY_GH_PROXY`(传 None)⇒ 只有 gh_proxy 那条红(off 档仍有 `https://gh-proxy.com/...` 候选),另五条绿; - `gh_proxy_override("")` 返回 Off ⇒ gh_proxy 那条的「出厂该套内置前缀」与回落用例的 `tuning == default` 同时红(两处都该红)。 --- crates/cli/src/commands/update.rs | 14 ++++- crates/cli/src/download.rs | 7 +-- crates/cli/src/download/tests.rs | 83 ++++++++++++++++++++++++++---- crates/core/src/download/config.rs | 10 ++-- 4 files changed, 95 insertions(+), 19 deletions(-) diff --git a/crates/cli/src/commands/update.rs b/crates/cli/src/commands/update.rs index 830a685..cf96e0c 100644 --- a/crates/cli/src/commands/update.rs +++ b/crates/cli/src/commands/update.rs @@ -34,8 +34,10 @@ //! 去掉 `--dest` 会把它真改名换掉(`crates/cli/tests/update_e2e_test.rs` 的注释写了这件事)。 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}; @@ -53,9 +55,19 @@ fn current_version() -> Result { .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 { + super::install::build_engine(ctx) +} + /// 造一个内核 job(源 = GitHub / `ULL_UPDATE_DIR`,暂存 = 数据根缓存,目标 = 本进程 exe)。 fn job(ctx: &Ctx, dest: Option) -> Job { - let engine = super::install::build_engine(ctx); + let engine = engine(ctx); let job = Job::for_process(default_source(engine), &ctx.layout().cache()); match dest { Some(dest) => job.with_dest(dest), diff --git a/crates/cli/src/download.rs b/crates/cli/src/download.rs index b02d308..050395f 100644 --- a/crates/cli/src/download.rs +++ b/crates/cli/src/download.rs @@ -1,11 +1,12 @@ //! CLI 侧的下载引擎:把 CLI 自己的 `launcher.json` 接到 core 的下载调优上(M203)。 //! -//! 三条下载命令(`install` / `java` / `pack`)此前各自 `DownloadEngine::new(EngineConfig::default(), …)`: +//! `install` / `java` / `pack` 三条命令此前各自 `DownloadEngine::new(EngineConfig::default(), …)`: //! 用户在设置里配的 `download.concurrency` / `download.retry_limit` / `download.gh_proxy` 一概不生效。 //! M193 把出厂并发从 64 改成 8 之后,它们还**静默**跟着降了——「设置项说假话」的同期病灶 -//! (`docs/download-settings.md` §3.1 记着这三条 ❌)。 +//! (`docs/download-settings.md` §3.1 记着这三条 ❌)。第四条路径 `update`(自更新)复用 `install` +//! 的装配,也随之吃设置;它只下一个 artifact,所以并发在那条路径上不可观测。 //! -//! 现在三处一律经 [`engine`] 取引擎(进程内共享,见 `core::download::shared`): +//! 现在四条一律经 [`engine`] 取引擎(进程内共享,见 `core::download::shared`): //! //! ```text //! <数据根>/config/launcher.json(与 `ull config set` 同一份) → setting store → DownloadTuning diff --git a/crates/cli/src/download/tests.rs b/crates/cli/src/download/tests.rs index daca031..4fe04b5 100644 --- a/crates/cli/src/download/tests.rs +++ b/crates/cli/src/download/tests.rs @@ -1,8 +1,10 @@ -//! [`super`] 的单测(M203):`launcher.json` 的 `download.*` 真的进到三条命令拿到的引擎里。 +//! [`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}; @@ -10,8 +12,9 @@ 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, + 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; @@ -57,13 +60,17 @@ fn ctx_at(dir: &TempDir) -> Ctx { 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(); +/// 直接写 `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 } /// 「引擎真的按这个并发跑」:配置字段 + 闸门许可数,两处都读真实值。 @@ -131,7 +138,7 @@ fn java_engine_takes_the_concurrency_and_retry_from_the_setting_store() { ); } -/// **`pack` 吃设置**(三条命令各有一条独立断言:把任意一处改回裸引擎,只有那一条红)。 +/// **`pack` 吃设置**(四条命令各有一条独立断言:把任意一处改回裸引擎,只有那一条红)。 /// /// 抓得住的变异:`commands::pack::build_engine` 换回裸 `EngineConfig::default()` ⇒ 2 处红。 #[test] @@ -146,6 +153,62 @@ fn pack_engine_takes_the_concurrency_from_the_setting_store() { ); } +/// **`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 { + 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` 的返回类型没有错误通道,坏设置只进 @@ -179,9 +242,9 @@ fn missing_or_illegal_values_fall_back_to_the_factory_defaults_silently() { json!({ "download": { "concurrency": "八", "retry_limit": "很多" } }), ), ] { - write_launcher_config(&ctx, document); + let written = write_launcher_config(&ctx, document); // 先证明这份文件**真的**是坏的(否则「回落」只是键不存在,用例就成了空断言)。 - let store = SettingStore::load(ctx.launcher_config(), launcher_schema()); + let store = SettingStore::load(&written, launcher_schema()); assert!( !store.problems().is_empty(), "{case}:这份文件该被判为有问题" diff --git a/crates/core/src/download/config.rs b/crates/core/src/download/config.rs index ddcda41..331dbf4 100644 --- a/crates/core/src/download/config.rs +++ b/crates/core/src/download/config.rs @@ -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"; /// 引擎配置。