fix(m203): 给第 4 条吃设置的路径(ull update)补打点,并清掉 KEY_GH_PROXY 的腐化 doc

复审 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` 同时红(两处都该红)。
This commit is contained in:
AskaEth committed 2026-11-09 10:07:41 +08:00
1 parent b31ca636d1
commit 7995f62908
4 files changed
+95 -19

No files matched your search

+13 -1
View File
@@ -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<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 engine = engine(ctx);
let job = Job::for_process(default_source(engine), &ctx.layout().cache());
match dest {
Some(dest) => job.with_dest(dest),
+4 -3
View File
@@ -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
+73 -10
View File
@@ -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<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` 的返回类型没有错误通道,坏设置只进
@@ -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}:这份文件该被判为有问题"
+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";
/// 引擎配置。