From e8544b19a766675abd0ac81751818827afee371b Mon Sep 17 00:00:00 2001 From: worker-dlmsettings Date: Mon, 9 Nov 2026 02:00:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(m193):=20=E4=B8=8B=E8=BD=BD=E5=B9=B6?= =?UTF-8?q?=E5=8F=91/=E9=87=8D=E8=AF=95/gh-proxy=20=E7=9C=9F=E6=8E=A5?= =?UTF-8?q?=E8=BF=9B=E5=BC=95=E6=93=8E=EF=BC=88schema=20=E4=B8=8E=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E5=90=8C=E6=BA=90=E5=B8=B8=E9=87=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - download.concurrency / download.retry_limit 以前引擎侧零消费者(恒用 EngineConfig::default() 的 64,而 schema 说 8)。现在默认值与硬上限收成 download/config.rs 的常量,schema、EngineConfig::default()、Semaphore 容量 三处同源;宿主把三个键翻译成 DownloadTuning 后经 shared::set_tuning(按数据根) 灌进装配点,改了设置下一次取引擎就生效。 - download.gh_proxy 三态(留空=跟随内置默认 / off=关 / 前缀=覆盖)下沉到 shared::build,所有走共享引擎的下载路径(内容库/版本清单/字体/安装)一致。 - 删掉 MirrorTable::set_gh_proxy 与 default_gh_proxy 两个改了没用的入口。 - 下载设置页把三条假演示值换成说语义的中文行(含三态说明与 1~64 硬上限)。 - 守卫:探针读 Semaphore 真实许可数(不是读配置),每条配变异证明。 - docs/download-settings.md 记下取证表(另四个键仍零消费,已立 finding)。 --- .../app-iced/src/pages/settings/download.rs | 27 +- .../pages/settings/tests/download_section.rs | 74 +++- crates/core/src/download/config.rs | 362 +++++++++++++++++- crates/core/src/download/engine.rs | 41 +- crates/core/src/download/mirrors.rs | 37 +- crates/core/src/download/shared.rs | 124 +++++- crates/core/src/setting/keys.rs | 36 +- crates/core/tests/download/mirrors_test.rs | 9 +- crates/presenter/src/host/dispatch.rs | 5 + crates/presenter/src/host/engine.rs | 12 +- crates/presenter/src/host/settings.rs | 89 +---- .../presenter/src/host/settings/download.rs | 174 +++++++++ crates/presenter/src/host/settings_tests.rs | 49 +++ docs/download-settings.md | 119 ++++++ docs/images/downloads-settings-zh.png | Bin 0 -> 95845 bytes 15 files changed, 958 insertions(+), 200 deletions(-) create mode 100644 crates/presenter/src/host/settings/download.rs create mode 100644 docs/download-settings.md create mode 100644 docs/images/downloads-settings-zh.png diff --git a/crates/app-iced/src/pages/settings/download.rs b/crates/app-iced/src/pages/settings/download.rs index b3d8a09..e064dae 100644 --- a/crates/app-iced/src/pages/settings/download.rs +++ b/crates/app-iced/src/pages/settings/download.rs @@ -1,9 +1,25 @@ -//! 设置页「Downloads」分组:首选源 / 并发数 / 校验(演示行)。 +//! 设置页「Downloads」分组:并发 / 单源尝试次数 / GitHub 加速前缀(M193)。 //! //! **这一组归 download 线**:接真控件时在本文件把 `body` 换成 `Some(download::body)`(渲染器也写在本文件), //! `settings/mod.rs` 的注册表不用动。 //! //! 注意与下载页(`pages/download*`)的区别:那是「装哪个版本」,这里是「怎么下」的默认值。 +//! +//! ## 为什么这三行是「说语义」而不是「说当前值」(M193) +//! +//! 原来这三行是**写死的演示值**(`("Concurrency", "8 threads")` / `("Preferred source", +//! "Automatic (region)")` / `("Verify checksums", "On")`)。用户实测把它们当成真值投诉过 +//! (「它默认显示 8,可我改成 2 也没变」)——因为 iced 侧**根本没有设置数据通路**: +//! `pages::Model` 没有设置快照、`grep -rn "SettingsChanged" crates/app-iced/src` 零命中、 +//! [`State`] 是个空 struct 且无人填。而这三行里「8 threads」当时还是**假的**:引擎真值曾是 64 +//! (schema 说 8),「Verify checksums」更是没有任何校验开关(校验恒开)。 +//! +//! 所以这一轮把假值换成**不会撒谎的语义行**:区间、默认值、「留空 / off / 前缀」三态各是什么。 +//! 真值(用户在 `launcher.json` 里填的那个数)不在这里显示——**宁可不说,也不说假的**。 +//! 真正的可改控件要等一条 presenter 快照 → app → `Groups.download` 的数据通路,另立 mission。 +//! +//! **生效保证**:这三条设置真的会被引擎读走(`core::download::shared::set_tuning`,宿主侧 +//! 翻译在 `presenter::host::settings`),所以「行里的语义」与「引擎行为」是同一份契约。 use unlucky_design::icons::IconKind; @@ -14,9 +30,11 @@ pub(super) const GROUP: Group = Group { name: "Downloads", icon: IconKind::Download, rows: &[ - ("Preferred source", "Automatic (region)"), - ("Concurrency", "8 threads"), - ("Verify checksums", "On"), + ("并发数", "出厂 8 · 可调 1~64"), + ("单源尝试次数", "出厂 3 · 含首次 · 换源另算"), + ("GitHub 加速前缀", "留空 = 跟随内置默认"), + ("关掉它", "填 off = 关闭"), + ("换掉它", "填 URL 前缀 = 覆盖"), ], body: None, }; @@ -24,6 +42,7 @@ pub(super) const GROUP: Group = Group { /// Downloads 分组的运行期状态(M101 r2:**只归 download 线**)。 /// /// 加字段只改本文件:`Groups` 只认 `Groups.download` 这一个字段。 +/// 现在仍然是空的——真值的数据通路还没接(见文件头「为什么这三行是说语义」)。 #[derive(Debug, Default)] pub struct State {} diff --git a/crates/app-iced/src/pages/settings/tests/download_section.rs b/crates/app-iced/src/pages/settings/tests/download_section.rs index f3ca8bc..46b3fc7 100644 --- a/crates/app-iced/src/pages/settings/tests/download_section.rs +++ b/crates/app-iced/src/pages/settings/tests/download_section.rs @@ -2,7 +2,7 @@ //! 一条 section 断言都没有;这 3 条是新增,不是搬移)。 //! //! **归 download 线**。接真控件那天要改**两个**文件(M101 r2 删掉了原先的第 3 处跨 section 绊线): -//! `settings/download.rs` + 本文件 `the_downloads_group_is_still_demo_rows`。 +//! `settings/download.rs` + 本文件。 //! //! 别把它和下载页(`pages/download*`)搞混:那是「装哪个版本」,这一组是「怎么下」的默认值。 @@ -11,18 +11,20 @@ use unlucky_design::icons::IconKind; /// 这一组的行集合(标签 + 值)。 /// -/// 抓得住:① 四条线并行合并时把别人的行粘到本文件(四份 `GROUP` 长得几乎一样,最容易整块贴错); -/// ② 漏掉/多出一条行;③ 标签与值错位(`("Concurrency", "8 threads")` 被写成 `"Off"`)。 -/// 抓不住:值对不对(`Automatic (region)` 是不是本机真选中的源)——那是接线后真数据源的事。 +/// 抓得住:① 四条线并行合并时把别人的行粘到本文件(六份 `GROUP` 长得几乎一样,最容易整块贴错); +/// ② 漏掉/多出一条行;③ 标签与值错位。 +/// 抓不住:值对不对——这三行**本来就不是值**(M193 起改成语义行,见下一条用例)。 #[test] -fn the_downloads_group_shows_source_concurrency_and_checksums() { +fn the_downloads_group_shows_concurrency_retries_and_the_gh_proxy_key() { let rows: Vec<(&str, &str)> = download::GROUP.rows.to_vec(); assert_eq!( rows, vec![ - ("Preferred source", "Automatic (region)"), - ("Concurrency", "8 threads"), - ("Verify checksums", "On"), + ("并发数", "出厂 8 · 可调 1~64"), + ("单源尝试次数", "出厂 3 · 含首次 · 换源另算"), + ("GitHub 加速前缀", "留空 = 跟随内置默认"), + ("关掉它", "填 off = 关闭"), + ("换掉它", "填 URL 前缀 = 覆盖"), ] ); } @@ -33,17 +35,59 @@ fn the_downloads_group_keeps_its_icon() { assert_eq!(download::GROUP.icon, IconKind::Download); } -/// **绊线**:这一组还没接真控件,画的是演示行。 +/// **M193 换了绊线的内容,但没撤绊线**。 /// -/// 抓得住:download 线接上了 `body` 却没更新这里的期望(那会让本文件静默变成假绿)。 -/// 抓不住:界面上真控件长什么样。 +/// 原绊线是 `the_downloads_group_is_still_demo_rows`:断言 `body.is_none()`(这一组还没接真控件)。 +/// 它存在的意义是「防止演示值回潮」。M193 撤掉的只是**演示值**(`("Concurrency", "8 threads")` +/// 这类):那个 8 与引擎真实的 64 对不上,用户按它调设置自然没效果。 /// -/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**(M101 r2 起不必再去 -/// `tests/mod.rs` 报到:那条跨 section 聚合绊线已删)。 +/// 现在改成两条更硬的断言: +/// +/// 1. **仍然没有真控件**(`body.is_none()`)——真值的通路还没接(`State` 是空 struct、 +/// iced 侧没有设置快照),所以这一组还是静态行;接上那天把这条改成 `is_some()` 并接数据。 +/// 2. **行里不许再出现「当前值」形态的字面量**:三行必须把「出厂值 / 区间 / 三态」讲清楚, +/// 且 gh-proxy 那一行必须**同时**说清「留空 = 跟随内置默认」与「填 off = 关闭」—— +/// 这两者最容易被混(一个「我没决定」,一个是「我明确不要」)。 +/// +/// 变异证明:把 `download.rs` 的 gh_proxy 行改回 `("Preferred source", "Automatic (region)")` +/// ⇒ 第 2 条红;把 `enabled` 语义写成「留空 = 关闭」⇒ 第 2 条红。 #[test] -fn the_downloads_group_is_still_demo_rows() { +fn the_downloads_rows_state_semantics_and_never_fake_a_current_value() { assert!( download::GROUP.body.is_none(), - "Downloads 还没接真控件:接了就改本断言" + "Downloads 还没接真控件:接了就改成 is_some() 并接数据通路" ); + + let joined = download::GROUP + .rows + .iter() + .map(|(label, value)| format!("{label} {value}")) + .collect::>() + .join(" | "); + + for required in [ + // 三态里最容易被混的两档,必须都在(缺一个就是「设置项说假话」的种子)。 + "留空 = 跟随内置默认", + "填 off = 关闭", + "填 URL 前缀 = 覆盖", + // 硬上限必须在界面上说清(任务书的边界条款)。 + "1~64", + // 三个键各自是哪一行(标签本身也是契约:用户按它认设置)。 + "并发数", + "单源尝试次数", + "GitHub 加速前缀", + ] { + assert!( + joined.contains(required), + "行里缺少「{required}」:{joined}" + ); + } + + // 「当前值」形态的假数据:`8 threads` / `On` / `Automatic (region)` 都是当年被投诉的那类。 + for forbidden in ["threads", "Automatic (region)", "Verify checksums"] { + assert!( + !joined.contains(forbidden), + "又出现假的当前值「{forbidden}」:{joined}" + ); + } } diff --git a/crates/core/src/download/config.rs b/crates/core/src/download/config.rs index 8fcf892..e384ca6 100644 --- a/crates/core/src/download/config.rs +++ b/crates/core/src/download/config.rs @@ -1,17 +1,51 @@ //! 引擎配置:设置层(core::setting)直接映射到这里。 +//! +//! # 出厂默认值与设置 schema 的唯一来源(M193) +//! +//! 四个常量是**唯一**写死数字的地方:schema 的默认值/范围、`EngineConfig::default()`、 +//! `Semaphore` 容量全从它们取。此前 schema 写 8、引擎写 64 ⇒ 调并发完全无效(引擎侧零消费者)。 +//! 收成常量后「改一处另一个不变」会直接变红(守卫见 `shared.rs` 与本文件的用例)。 +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; use std::time::Duration; use super::backoff::BackoffPolicy; use super::chunked::ChunkedConfig; +use super::region::RegionProfile; use super::types::ResumePolicy; +/// 全局并发许可数的出厂默认(`download.concurrency` 的 schema 默认值同源)。 +pub const DEFAULT_MAX_CONCURRENCY: usize = 8; + +/// 全局并发许可的**硬上限**(`download.concurrency` 的 schema 上界同源)。schema 在写入口就拒绝 +/// 越界值,这里再夹一次是纵深防御(手改文件、老 schema 允许的大数)——9999 会把机器拖死。 +pub const MAX_CONCURRENCY: usize = 64; + +/// 每个源尝试次数的出厂默认(`download.retry_limit` 的 schema 默认值同源)。 +pub const DEFAULT_MAX_ATTEMPTS_PER_SOURCE: u32 = 3; + +/// 每个源尝试次数的硬上限(`download.retry_limit` 的 schema 上界同源)。 +pub const MAX_ATTEMPTS_PER_SOURCE: u32 = 10; + +/// 三个设置键名(层间契约):登记在 `setting::keys`,读取在宿主。只写在这里,两侧都引用常量。 +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 断言**——现在 +/// 只断言常量本身,别把这条弱断言当成可以随手删的。 +pub const KEY_GH_PROXY: &str = "download.gh_proxy"; + /// 引擎配置。 #[derive(Debug, Clone)] pub struct EngineConfig { - /// 全局并发许可数(tokio `Semaphore`),默认 64。 + /// 全局并发许可数(tokio `Semaphore`),默认 [`DEFAULT_MAX_CONCURRENCY`]。 pub max_concurrency: usize, - /// 每个源的尝试次数上限,默认 3。 + /// 每个源的尝试次数上限,默认 [`DEFAULT_MAX_ATTEMPTS_PER_SOURCE`]。 pub max_attempts_per_source: u32, /// 连接超时,默认 10s。 pub connect_timeout: Duration, @@ -32,8 +66,8 @@ pub struct EngineConfig { impl Default for EngineConfig { fn default() -> Self { Self { - max_concurrency: 64, - max_attempts_per_source: 3, + max_concurrency: DEFAULT_MAX_CONCURRENCY, + max_attempts_per_source: DEFAULT_MAX_ATTEMPTS_PER_SOURCE, connect_timeout: Duration::from_secs(10), read_timeout: Duration::from_secs(30), user_agent: concat!("UnluckyLauncher/", env!("CARGO_PKG_VERSION")).to_string(), @@ -44,3 +78,323 @@ impl Default for EngineConfig { } } } + +/// `download.gh_proxy` 的三种取值(三态语义见 [`gh_proxy_override`])。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum GhProxyOverride { + /// 空串:**没有覆盖**,跟随内置默认(地区判定 + 镜像表里的前缀)。 + #[default] + Builtin, + /// 明确关掉:一个 gh-proxy 候选都不生成(`RegionProfile::with_gh_proxy(None)`)。 + Off, + /// 用这个前缀覆盖(仍然只在镜像优先 / 国内判定时启用)。 + Prefix(String), +} + +/// 设置原文 → gh-proxy 三态(纯函数:`off` 与 URL 的边界在这里定死,不散落到宿主)。 +/// +/// **空串不等于关**:「我不想用加速」和「我还没决定」是两件事。空串 = 跟随内置默认, +/// 要关必须写 `off`(`none` / `direct` / `disable` / `disabled` / `no` 这些口语词一并认, +/// 大小写不敏感、前后空白忽略——用户拿哪个词表达「关掉」都合理,专门要求某一个会造出 +/// 「填了没生效」的坑)。 +#[must_use] +pub fn gh_proxy_override(raw: &str) -> GhProxyOverride { + let text = raw.trim(); + if text.is_empty() { + return GhProxyOverride::Builtin; + } + let lowered = text.to_ascii_lowercase(); + if matches!( + lowered.as_str(), + "off" | "none" | "direct" | "disable" | "disabled" | "no" + ) { + return GhProxyOverride::Off; + } + GhProxyOverride::Prefix(text.to_string()) +} + +/// 设置侧灌进来的下载调优(M193):并发 / 每个源的尝试次数 / gh-proxy 覆盖。 +/// +/// 这是「`launcher.json` 的三个键」到「引擎装配参数」之间**唯一**的翻译层:键名由宿主读(core +/// 不认识设置键名),默认值、合法区间、回落策略都在这里——否则「UI 说 8、引擎跑 64」会从缝里长回来。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DownloadTuning { + /// 全局并发许可数(已夹在 `1..=MAX_CONCURRENCY`)。 + pub max_concurrency: usize, + /// 每个源的尝试次数上限(已夹在 `1..=MAX_ATTEMPTS_PER_SOURCE`)。 + pub max_attempts_per_source: u32, + /// gh-proxy 覆盖。 + pub gh_proxy: GhProxyOverride, +} + +impl Default for DownloadTuning { + fn default() -> Self { + Self { + max_concurrency: DEFAULT_MAX_CONCURRENCY, + max_attempts_per_source: DEFAULT_MAX_ATTEMPTS_PER_SOURCE, + gh_proxy: GhProxyOverride::Builtin, + } + } +} + +impl DownloadTuning { + /// 设置原文 → 调优(**未填 / 越界 / 非法值一律回落出厂默认**,绝不报错阻断下载)。 + /// + /// 口径与 schema 的 `(min, max)` 同源但独立实现(用户可能手改文件绕过 `store.set`):键不存在 + /// → 出厂默认;落在区间内 → 原值;0 / 负数 / 超上限 → 出厂默认(**不夹到边界**:那是「想 9999 + /// 给 64」,界面仍会说谎)。 + #[must_use] + pub fn from_settings( + concurrency: Option, + attempts: Option, + gh_proxy: Option<&str>, + ) -> Self { + let default = Self::default(); + Self { + max_concurrency: concurrency + .and_then(|raw| usize::try_from(raw).ok()) + .filter(|raw| (1..=MAX_CONCURRENCY).contains(raw)) + .unwrap_or(default.max_concurrency), + max_attempts_per_source: attempts + .and_then(|raw| u32::try_from(raw).ok()) + .filter(|raw| (1..=MAX_ATTEMPTS_PER_SOURCE).contains(raw)) + .unwrap_or(default.max_attempts_per_source), + gh_proxy: gh_proxy.map_or(GhProxyOverride::Builtin, gh_proxy_override), + } + } + + /// 把调优盖到 `EngineConfig` 上(其余字段保持传入值)。 + #[must_use] + pub fn apply_to(&self, mut cfg: EngineConfig) -> EngineConfig { + cfg.max_concurrency = self.max_concurrency; + cfg.max_attempts_per_source = self.max_attempts_per_source; + cfg + } + + /// 把 gh-proxy 覆盖盖到档案上(`Builtin` 原样返回 = 跟随地区判定与镜像表)。 + #[must_use] + pub fn apply_gh_proxy(&self, profile: RegionProfile) -> RegionProfile { + match &self.gh_proxy { + GhProxyOverride::Builtin => profile, + GhProxyOverride::Off => profile.with_gh_proxy(None), + GhProxyOverride::Prefix(prefix) => profile.with_gh_proxy(Some(prefix)), + } + } +} + +/// 每个缓存根一份「宿主想要什么」(`engine_for` 构造时用它,见 [`wanted`])。按数据根而不是进程级: +/// 登记处本就是「一个数据根一个引擎」;进程级那份在多数据根时会互相踩(A 根改 2、B 根冲回 8)。 +fn wanted_slot() -> &'static Mutex> { + static WANTED: OnceLock>> = OnceLock::new(); + WANTED.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// 该数据根当前想要的调优(没灌过 = 出厂默认)。 +#[must_use] +pub fn wanted(cache_root: &Path) -> DownloadTuning { + wanted_slot() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(cache_root) + .cloned() + .unwrap_or_default() +} + +/// 记下该数据根想要的调优,返回**是否真的变了**(没变时调用方不该重建引擎)。 +pub fn publish(cache_root: &Path, next: DownloadTuning) -> bool { + let mut wanted = wanted_slot() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + // 归一化比较:「没灌过」与「灌了出厂默认」是同一件事,不能因为 `None != Some(default)` + // 就把每次取引擎都当成「参数变了」(那会每次重建引擎、把连接池与健康度记忆全丢掉)。 + let current = wanted.get(cache_root).cloned().unwrap_or_default(); + if current == next { + return false; + } + if next == DownloadTuning::default() { + // 默认值不占格子(也顺手回收测试造的临时根)。 + wanted.remove(cache_root); + } else { + wanted.insert(cache_root.to_path_buf(), next); + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::setting::keys::{launcher_schema, launcher_version}; + + /// **守门③**:schema 的默认值与 `EngineConfig::default()` / 调优默认值是同一个常量。 + /// + /// 变异证明(只改实现侧):把 `EngineConfig::default()` 的 `max_concurrency` 写回字面量 64 + /// ⇒ 本用例红在 `max_concurrency`;把 `setting/keys.rs` 的 `download.concurrency` 默认值写回 + /// 字面量 8 而常量改成 16 ⇒ 红在 schema 一侧。这条防的正是「两处各写一个数」长回来 + /// (8 vs 64 就是这么长出来的)。 + #[test] + fn the_schema_defaults_are_the_engine_defaults() { + let schema = launcher_schema(); + assert_eq!(schema.version(), launcher_version()); + let cfg = EngineConfig::default(); + let tune = DownloadTuning::default(); + + // 键名常量与 schema 登记是同一份(宿主按常量读,schema 按常量登记)。 + assert!(schema.spec(KEY_CONCURRENCY).is_some(), "{KEY_CONCURRENCY}"); + assert!(schema.spec(KEY_RETRY_LIMIT).is_some(), "{KEY_RETRY_LIMIT}"); + + let concurrency = schema.spec(KEY_CONCURRENCY).expect("键已登记"); + assert_eq!( + concurrency.default, + serde_json::json!(DEFAULT_MAX_CONCURRENCY) + ); + assert_eq!(cfg.max_concurrency, DEFAULT_MAX_CONCURRENCY); + assert_eq!(tune.max_concurrency, DEFAULT_MAX_CONCURRENCY); + + let retry = schema.spec(KEY_RETRY_LIMIT).expect("键已登记"); + assert_eq!( + retry.default, + serde_json::json!(DEFAULT_MAX_ATTEMPTS_PER_SOURCE) + ); + assert_eq!(cfg.max_attempts_per_source, DEFAULT_MAX_ATTEMPTS_PER_SOURCE); + assert_eq!( + tune.max_attempts_per_source, + DEFAULT_MAX_ATTEMPTS_PER_SOURCE + ); + } + + /// 硬上限:schema 的上界与引擎的夹取上限是同一个常量(防「schema 收 256、引擎不知情」)。 + /// 变异证明:只改 `MAX_CONCURRENCY` 而 schema 仍写 64 ⇒ 红。 + #[test] + fn the_hard_caps_are_single_sourced() { + let schema = launcher_schema(); + let concurrency = i64::try_from(MAX_CONCURRENCY).expect("装得下"); + let attempts = i64::from(MAX_ATTEMPTS_PER_SOURCE); + for (key, max) in [(KEY_CONCURRENCY, concurrency), (KEY_RETRY_LIMIT, attempts)] { + let spec = schema.spec(key).expect("键已登记"); + assert_eq!( + spec.kind, + crate::setting::spec::ValueKind::Int { min: 1, max } + ); + } + } + + /// 未填 / 非法值回落默认,不阻断(任务书的边界条款)。 + /// + /// 变异证明:把 `filter(|raw| (1..=MAX_CONCURRENCY).contains(raw))` 删掉 ⇒ `9999` 与 `0` + /// 两处断言红;把 `unwrap_or(default.max_concurrency)` 改成 `unwrap_or(1)` ⇒ `None` 断言红。 + #[test] + fn missing_or_illegal_values_fall_back_to_the_defaults() { + let tune = DownloadTuning::from_settings(None, None, None); + assert_eq!(tune, DownloadTuning::default()); + + let just_over = i64::try_from(MAX_CONCURRENCY).expect("上限装得下") + 1; + for bad in [0_i64, -1, 9_999, just_over] { + let tune = DownloadTuning::from_settings(Some(bad), Some(bad), None); + assert_eq!( + tune, + DownloadTuning::default(), + "{bad} 越界时应回落默认而不是夹到边界" + ); + } + + let tune = DownloadTuning::from_settings(Some(2), Some(1), None); + assert_eq!(tune.max_concurrency, 2); + assert_eq!(tune.max_attempts_per_source, 1); + } + + /// 三态:**空串 ≠ 关**(这条是「设置项说假话」的高发点,M197 的键语义同源)。 + #[test] + fn empty_gh_proxy_means_follow_builtin_not_off() { + assert_eq!(gh_proxy_override(""), GhProxyOverride::Builtin); + assert_eq!(gh_proxy_override(" "), GhProxyOverride::Builtin); + for off in [ + "off", "OFF", " none ", "direct", "disable", "disabled", "no", + ] { + assert_eq!(gh_proxy_override(off), GhProxyOverride::Off, "{off}"); + } + assert_eq!( + gh_proxy_override(" https://gh.example/ "), + GhProxyOverride::Prefix("https://gh.example/".to_string()) + ); + // 未填(键不存在)也是 Builtin,不是 Off。 + assert_eq!( + DownloadTuning::from_settings(None, None, None).gh_proxy, + GhProxyOverride::Builtin + ); + } + + /// `apply_gh_proxy` 的三档真的改到档案上、并且**改到候选链上**(`MirrorTable::expand()` 读的就是 + /// 档案里那个前缀,内容库/版本清单都走它)。这是「改键 ⇒ 候选链变」的 core 级一半。 + /// 变异证明:把 `Off` 分支改成原样返回 `profile` ⇒ `off` 那条红。 + #[test] + fn gh_proxy_override_reaches_the_region_profile_and_the_candidate_chain() { + use crate::download::mirrors::{MirrorTable, SOURCE_GH_PROXY}; + use crate::download::types::ResourceKind; + + let url = "https://github.com/o/r/releases/download/v1/a.jar"; + let table = MirrorTable::builtin(); + let chain = |profile: &RegionProfile| -> Vec { + table + .expand(url, ResourceKind::Other, profile) + .into_iter() + .map(|candidate| candidate.url) + .collect() + }; + + let mirror_first = RegionProfile::mirror_first(0); + let kept = DownloadTuning::default().apply_gh_proxy(mirror_first.clone()); + assert_eq!(kept.gh_proxy, mirror_first.gh_proxy, "Builtin 不该动档案"); + assert!( + chain(&kept).iter().any(|url| url.contains("gh-proxy")), + "出厂默认会套前缀" + ); + + let off = DownloadTuning::from_settings(None, None, Some("off")) + .apply_gh_proxy(mirror_first.clone()); + assert!(!off.gh_proxy_enabled(), "off 必须关掉 gh-proxy"); + assert_eq!(off.gh_proxy, None); + assert!( + !chain(&off).iter().any(|url| url.contains("gh-proxy")), + "关了就不该有候选" + ); + assert!( + table + .expand(url, ResourceKind::Other, &off) + .iter() + .all(|candidate| candidate.source != SOURCE_GH_PROXY), + "连 source 也不该出现" + ); + + let prefix = DownloadTuning::from_settings(None, None, Some("https://gh.example/")) + .apply_gh_proxy(mirror_first); + assert_eq!(prefix.gh_proxy.as_deref(), Some("https://gh.example/")); + assert!(prefix.gh_proxy_enabled()); + assert!( + chain(&prefix) + .iter() + .any(|url| url.starts_with("https://gh.example/")), + "覆盖前缀必须进候选链" + ); + } + + /// 每个数据根的调优互不串味(一个根改成 2 不该改另一个根的引擎)。 + /// 变异证明:把 `wanted`/`publish` 的键改回单一进程级槽位 ⇒ 第二个根读到 2,红。 + #[test] + fn tuning_is_per_cache_root_and_no_change_is_reported_as_such() { + let a = PathBuf::from("/tmp/ull-tuning-a"); + let b = PathBuf::from("/tmp/ull-tuning-b"); + assert_eq!(wanted(&a), DownloadTuning::default()); + + let changed = DownloadTuning::from_settings(Some(2), None, None); + assert!(publish(&a, changed.clone()), "真变了要报变更"); + assert_eq!(wanted(&a), changed); + assert!(!publish(&a, changed.clone()), "同值不该报变更"); + assert_eq!(wanted(&b), DownloadTuning::default(), "别的根不受影响"); + + assert!( + publish(&a, DownloadTuning::default()), + "恢复默认也是一次变更" + ); + assert_eq!(wanted(&a), DownloadTuning::default()); + } +} diff --git a/crates/core/src/download/engine.rs b/crates/core/src/download/engine.rs index 091521e..0063bb1 100644 --- a/crates/core/src/download/engine.rs +++ b/crates/core/src/download/engine.rs @@ -5,18 +5,15 @@ //! ```text //! 校验请求 → 内容寻址缓存直取(零网络) //! → 候选规划(镜像表展开 + 健康度排序) -//! → Semaphore 限流 +//! → Semaphore 限流 ← 容量来自设置(M193,见 config.rs) //! → 候选源外循环(换源),每源内循环(指数退避 + 抖动) //! → 续传判定(保守)→ 单连接 / 多连接分块 → 流式校验 → 原子落盘 //! → 失败则保留中间文件供下次续传 //! ``` //! -//! 最后一步保留的是**本会话命名的**中间文件(`.ullpart--`,不再固定 -//! 叫 `.ullpart`):下次(甚至下次启动、重启后再下同一个文件)由 `PartFile::for_dest` -//! 在目标目录里认领它并改名成新的会话名,续传判定照旧。详细身份规则见 `atomic.rs` 模块头的 -//! 「中间文件的身份」。 -//! -//! 具体传输细节在 `transfer.rs`,错误分类在 `http.rs`,本文件只负责顺序与策略。 +//! 中间文件是本会话命名的(`.ullpart--`),下次(甚至下次启动)由 +//! `PartFile::for_dest` 认领并改名,续传判定照旧(身份规则见 `atomic.rs` 模块头)。传输细节在 +//! `transfer.rs`,错误分类在 `http.rs`,本文件只负责顺序与策略。 use std::path::Path; use std::sync::Arc; @@ -27,7 +24,7 @@ use tokio::sync::Semaphore; use super::atomic::PartFile; use super::backoff::Jitter; use super::cache::{now_ms, ContentCache}; -use super::config::EngineConfig; +use super::config::{EngineConfig, MAX_CONCURRENCY}; use super::error::{DownloadError, DownloadErrorKind}; use super::health::HealthRegistry; use super::http; @@ -72,8 +69,10 @@ impl DownloadEngine { cache: Arc, ) -> Self { let http = http::build_client(&cfg).unwrap_or_else(|_| reqwest::Client::new()); - let sem = Arc::new(Semaphore::new(cfg.max_concurrency.max(1))); - let seed = now_ms() ^ (u64::from(cfg.max_concurrency as u32) << 32); + // 硬上限夹取(M193):手改的 launcher.json / CLI 自拼的 cfg 都可能写着 9999,在唯一构造点再夹一次。 + let permits = cfg.max_concurrency.clamp(1, MAX_CONCURRENCY); + let sem = Arc::new(Semaphore::new(permits)); + let seed = now_ms() ^ (u64::from(u32::try_from(permits).unwrap_or(u32::MAX)) << 32); // 纯记账(M165):卡在构造这个点上,才统计得到绕过登记处的调用点(守卫靠 `constructions`)。 super::shared::record_build(cache.root()); Self { @@ -102,10 +101,15 @@ impl DownloadEngine { &self.cfg } - /// HTTP 客户端(同一条连接池与超时配置)。 - /// - /// 给「一锤子查询」用——例如 Adoptium 的版本查询(`core::game::java::api`): - /// 没必要为了一个 GET 再造一个 client,代理/超时/证书栈都在这里统一。 + /// **真实并发闸门**此刻的许可数(探针,M193):读 [`Self::acquire`] 会去抢的那张 + /// `Semaphore`,**不是** `config().max_concurrency`。有下载在飞时偏小,空闲等于容量。 + #[must_use] + pub fn concurrency_probe(&self) -> usize { + self.sem.available_permits() + } + + /// HTTP 客户端(同一条连接池与超时配置):给「一锤子查询」用(如 Adoptium 版本查询), + /// 没必要为了一个 GET 再造一个 client。 #[must_use] pub fn client(&self) -> &reqwest::Client { &self.http @@ -243,7 +247,7 @@ impl DownloadEngine { } Err(err) => { // 4xx(NotFound)不算「源坏」:院校镜像只留最新补丁,请求旧补丁必然 404, - // 记成失败会把唯一可用镜像冷却掉(M33/M34 实测:TUNA 每个目录只保 1 个归档)。 + // 记成失败会把唯一可用镜像冷却掉(M33/M34 实测:TUNA 每目录只保 1 个归档)。 if err.kind == DownloadErrorKind::NotFound { self.health.record_missing( &candidate.source, @@ -346,11 +350,8 @@ impl DownloadEngine { }) } - /// 该源此刻给几次尝试: - /// - /// * 冷却中 → 1 次(这次就是「恢复探测」,失败会重新冷却); - /// * 刚出冷却、还没洗白 → 也只给 1 次(`is_probe()`,别让死源每次到期都吃满 3 次); - /// * 正常 → 配置的 `max_attempts_per_source`。 + /// 该源此刻给几次尝试:冷却中 / 刚出冷却未洗白 → 1 次(那就是恢复探测,别让死源每次到期 + /// 都吃满预算);正常 → 设置里的 `download.retry_limit`(= `max_attempts_per_source`)。 pub(crate) fn attempts_for(&self, source: &str, kind: ResourceKind, now_ms: u64) -> u32 { let health = self.health.health_of(source, kind); if health.is_cooling_down(now_ms) || health.is_probe(now_ms) { diff --git a/crates/core/src/download/mirrors.rs b/crates/core/src/download/mirrors.rs index e16b6cb..e48e3bc 100644 --- a/crates/core/src/download/mirrors.rs +++ b/crates/core/src/download/mirrors.rs @@ -175,49 +175,18 @@ impl MirrorTable { self.rules.insert(0, rule); } - /// 覆盖 gh-proxy 前缀(设置项);传 `None` 表示不默认启用。 - pub fn set_gh_proxy(&mut self, prefix: Option<&str>) { - match prefix { - Some(prefix) => { - if let Some(proxy) = self.gh_proxies.first_mut() { - proxy.prefix = prefix.to_string(); - proxy.enabled = true; - } else { - self.gh_proxies.push(GhProxy { - id: "gh-proxy".to_string(), - name: "自定义 gh-proxy".to_string(), - prefix: prefix.to_string(), - enabled: true, - }); - } - } - None => { - for proxy in &mut self.gh_proxies { - proxy.enabled = false; - } - } - } - } - /// 找到第一条命中的前缀规则。 #[must_use] pub fn prefix_rule_for(&self, url: &str, kind: ResourceKind) -> Option<&PrefixRule> { self.rules.iter().find(|rule| rule.matches(url, kind)) } - /// 清单里第一个启用的 gh-proxy 前缀(默认值来源;设置层用它初始化地区档案)。 - #[must_use] - pub fn default_gh_proxy(&self) -> Option<&str> { - self.gh_proxies - .iter() - .find(|proxy| proxy.enabled && !proxy.prefix.is_empty()) - .map(|proxy| proxy.prefix.as_str()) - } - /// GitHub host 且允许时套 gh-proxy 前缀。 /// /// **生效前缀的唯一真相源是 `region.gh_proxy`**(设置层/地区判定写入,`None` = 用户关闭)。 - /// 表格里的 `gh_proxies` 只是「已知可用前缀」清单,配合 `default_gh_proxy()` 给设置做默认值。 + /// 表格里的 `gh_proxies` 只是「已知可用前缀」清单(诊断页可读),**改它不会生效**——M193 + /// 删掉了原先那个 `MirrorTable::set_gh_proxy`,正是因为它改了这张表而 `expand()` 读的是档案, + /// 属于「看起来能改、其实没人读」的入口。 #[must_use] pub fn gh_proxy_candidates(&self, url: &str, region: &RegionProfile) -> Vec { if region.mode != RegionMode::MirrorFirst { diff --git a/crates/core/src/download/shared.rs b/crates/core/src/download/shared.rs index be5231d..f3a59b6 100644 --- a/crates/core/src/download/shared.rs +++ b/crates/core/src/download/shared.rs @@ -32,23 +32,23 @@ //! 只 forget 的话它会一直用旧实例(含旧地区档案)直到进程结束。所以「重新检测地区」那条 //! 设置动作除了 [`forget`] 还调 `CoreHost::refresh_update_factory()` 把新引擎接过去 //! (M165 复审 P2-1:这是本模块文档原先说漏的一半)。 -//! * **并发多少**:`EngineConfig::default().max_concurrency`(64)现在是**进程级**上限, -//! 不再是「每请求 64」。单请求独占时与从前一致;多请求并发时它们分摊这 64 张许可。 +//! * **并发多少**(M193):容量来自设置 `download.concurrency`(出厂 8、硬上限 64,见 [`set_tuning`]), +//! 而且是**进程级**上限——多请求并发时分摊这批许可,不是「每请求 8」。 //! * **镜像切换 / 失效**:一律走引擎原有的候选规划 + 健康度冷却,本模块不参与判定;唯一可见的 //! 差别是健康度记忆现在真的跨请求累积(这本就是它存在的理由)。 //! //! # 与 [`super::blocking::block_on`] 的绑定关系 //! //! `reqwest` 的连接池绑在**驱动连接的运行时**上:引擎复用了而每次 `block_on` 都新起一个运行时, -//! 池里的空闲连接就属于已经销毁的运行时,复用会退化成「拿到的都是死连接」。所以引擎复用必须 -//! 配运行时常驻(`blocking` 模块那头一并改成进程内唯一运行时)。两者是同一件事的两半。 +//! 池里的空闲连接就属于已销毁的运行时,复用会退化成「拿到的都是死连接」。所以引擎复用必须配 +//! 运行时常驻(`blocking` 那一头一并改成进程内唯一运行时)。两者是同一件事的两半。 use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; use super::cache::{now_ms, ContentCache}; -use super::config::EngineConfig; +use super::config::{publish, wanted, DownloadTuning, EngineConfig}; use super::engine::DownloadEngine; use super::health::HealthRegistry; use super::mirrors::MirrorTable; @@ -102,9 +102,8 @@ pub fn engine_for(cache_root: impl AsRef) -> Arc { /// 让该数据根的共享引擎失效(下次 [`engine_for`] 重新构造)。 /// /// **只让登记处不再持有那一份**:已经拿到 `Arc` 的人继续用旧的。短命请求自然结束;长命持有者 -/// (presenter 的自更新 `Factory`)必须自己重建,否则它会一直用旧实例——本仓在「重新检测地区」 -/// 的设置动作里调 `CoreHost::refresh_update_factory()` 补上这一步(见模块文档「`forget` 到什么范围」)。 -/// 返回是否真淘汰了一份。 +/// (presenter 的自更新 `Factory`)必须自己重建——「重新检测地区」那条设置动作就补调了 +/// `CoreHost::refresh_update_factory()`。返回是否真淘汰了一份。 pub fn forget(cache_root: impl AsRef) -> bool { registry() .lock() @@ -116,9 +115,7 @@ pub fn forget(cache_root: impl AsRef) -> bool { /// 记一笔「这个缓存根上又造了一个引擎」。 /// /// **唯一调用点是 [`DownloadEngine::new`]**(不是 [`engine_for`]):记账卡在构造这个卡口上, -/// 于是 [`constructions`] 统计的是**进程里真实发生过几次构造**,与「谁造的」无关。 -/// 若只在 `engine_for` 里记账,任何绕过登记处、自己 `DownloadEngine::new` 的调用点都统计不到 -/// (M165 复审备注 2 的诉求)。 +/// [`constructions`] 才是「进程里真实构造过几次」,与谁造的无关(M165 复审备注 2)。 pub(crate) fn record_build(cache_root: &Path) { *ledger() .lock() @@ -127,10 +124,8 @@ pub(crate) fn record_build(cache_root: &Path) { .or_default() += 1; } -/// 该数据根在本进程里**实际构造过几次引擎**(不论谁构造的)。 -/// -/// 这是给守卫与诊断用的:跨请求复用成立时,同一个根无论被请求多少次,这个数都停在 `1` -/// (`forget` 之后每重建一次 +1)。只断言「结果一样」测不出复用——引擎建几次都能下同一个文件。 +/// 该数据根在本进程里**实际构造过几次引擎**(不论谁构造的):复用成立时停在 `1`(`forget` +/// 之后每重建一次 +1)。只断言「结果一样」测不出复用——引擎建几次都能下同一个文件。 #[must_use] pub fn constructions(cache_root: impl AsRef) -> usize { ledger() @@ -141,9 +136,14 @@ pub fn constructions(cache_root: impl AsRef) -> usize { .unwrap_or(0) } -/// 真实构造(**唯一**一处 `DownloadEngine::new` 的宿主侧装配)。 +/// 真实构造(**唯一**一处 `DownloadEngine::new` 的宿主侧装配)。装配参数从该数据根的 +/// [`wanted`] 取,不用裸 `EngineConfig::default()`(M193):并发 → `Semaphore` 容量、尝试次数 → +/// `attempts_for()` 上限、gh_proxy → 地区档案(`MirrorTable::expand()` 只认档案里那个前缀,所以 +/// 内容库 / 版本清单 / 字体 / 安装**所有**路径一起生效)。值由宿主翻译后经 [`set_tuning`] 灌入—— +/// core 不认识设置键名,也不依赖 `core::setting`。 fn build(cache_root: &Path) -> DownloadEngine { let now = now_ms(); + let tune = wanted(cache_root); 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); @@ -151,14 +151,30 @@ fn build(cache_root: &Path) -> DownloadEngine { profile }); DownloadEngine::new( - EngineConfig::default(), + tune.apply_to(EngineConfig::default()), Arc::new(MirrorTable::builtin()), - Arc::new(region), + Arc::new(tune.apply_gh_proxy(region)), Arc::new(HealthRegistry::open_in(cache_root, now)), Arc::new(ContentCache::open(cache_root)), ) } +/// 换该数据根的下载调优(宿主取引擎前 / 设置变更时调用),返回**是否真的变了**。变了就清掉该根的 +/// 登记项,下次 [`engine_for`] 按新参数重建——否则「改了设置要重启才生效」。已拿到 `Arc` 的长命 +/// 持有者继续用旧的(与 [`forget`] 同一条边界);自更新 `Factory` 由 dispatch 侧换引擎。 +/// **锁序**:`publish` 的锁先释放再拿登记处的锁,[`build`] 反之——两段不嵌套。 +pub fn set_tuning(cache_root: impl AsRef, next: DownloadTuning) -> bool { + let key = key_of(cache_root.as_ref()); + if !publish(&key, next) { + return false; + } + let _ = registry() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(&key); + true +} + #[cfg(test)] mod tests { use super::*; @@ -308,4 +324,76 @@ mod tests { assert_eq!(conns, 1, "两次下载应复用同一条连接"); std::fs::remove_dir_all(&dir).ok(); } + + /// **守门①②**:设置说 2 ⇒ 引擎**真实的并发闸门**就是 2(探针读 `Semaphore`,不是读配置); + /// 同一口装配点上的 gh-proxy 三档也真的改变 `plan()` 的候选链。 + /// + /// 变异证明(只改实现侧):① `build` 的 `tune.apply_to(...)` 换回裸 `EngineConfig::default()` + /// ⇒ 探针 8,红;② `tune.apply_gh_proxy(region)` 换回 `region` ⇒ `off` 档仍有候选,红; + /// ③ 删掉 `set_tuning` 里那条 `remove` ⇒ 换参数后交回旧引擎(探针 2 ≠ 5),红。 + /// gh-proxy 的**端到端**一半(schema 键 → 调优)要等 M197 登记该键,这里只守「调优 → 候选链」。 + #[test] + fn the_setting_and_the_gh_proxy_override_reach_the_engine() { + use super::super::types::{DownloadRequest, ResourceKind}; + use super::super::RegionProfile; + + let dir = root("tune"); + // 镜像优先的档案:gh-proxy 只在这个模式下才生成候选。 + store_cached( + &dir.join("region.json"), + &RegionProfile::mirror_first(now_ms()), + ) + .expect("地区档案"); + let req = DownloadRequest::new( + ResourceKind::Other, + "https://github.com/o/r/releases/download/v1/a.jar", + dir.join("a.jar"), + ); + let chain = |dir: &Path| -> Vec { + engine_for(dir) + .plan(&req, 0) + .into_iter() + .map(|c| c.url) + .collect() + }; + let set = |c: Option, a: Option, g: &str| { + set_tuning(&dir, DownloadTuning::from_settings(c, a, Some(g))) + }; + + set(Some(2), Some(1), "off"); + let engine = engine_for(&dir); + assert_eq!(engine.concurrency_probe(), 2, "闸门必须真是 2 张许可"); + assert_eq!( + engine.config().max_attempts_per_source, + 1, + "尝试次数同一条路" + ); + let off = chain(&dir); + assert!(!off.iter().any(|url| url.contains("gh-proxy")), "{off:?}"); + + set(Some(5), None, "https://gh.example/"); + let rebuilt = engine_for(&dir); + assert!(!Arc::ptr_eq(&engine, &rebuilt), "参数变了必须换引擎"); + assert_eq!(rebuilt.concurrency_probe(), 5, "新闸门吃新值"); + let over = chain(&dir); + assert!( + over.iter() + .any(|url| url.starts_with("https://gh.example/")), + "覆盖前缀必须出现在候选链里:{over:?}" + ); + + // 硬上限(纵深防御):宿主绕开 `from_settings` 直接塞 9999,闸门也只能开到 64。 + let wild = DownloadTuning { + max_concurrency: 9_999, + ..DownloadTuning::default() + }; + set_tuning(&dir, wild); + assert_eq!( + engine_for(&dir).concurrency_probe(), + super::super::config::MAX_CONCURRENCY, + "9999 张许可不能真开" + ); + + std::fs::remove_dir_all(&dir).ok(); + } } diff --git a/crates/core/src/setting/keys.rs b/crates/core/src/setting/keys.rs index ada28a0..687e3ba 100644 --- a/crates/core/src/setting/keys.rs +++ b/crates/core/src/setting/keys.rs @@ -5,6 +5,9 @@ use super::catalog::SettingSchema; use super::schema::{SchemaId, SchemaVersion}; use super::spec::{AiPolicy, SettingSpec}; +// M193:下载两键的默认值与硬上限的唯一来源在 `download::config`(引擎侧),这里只登记成 schema。 +use crate::download::config as download; + /// `core.data_mode` 允许值。 pub const DATA_MODES: &[&str] = &["portable", "standard", "custom"]; /// `download.region` 允许值。 @@ -45,23 +48,18 @@ pub mod update; /// /// `1.1.0` 登记 D20 实例隔离键(`instances.mode` 等)。 /// `1.2.0` 登记 D23 的 JDK 落点与发行版优先级键(`java.installRoot` / `java.vendorPriority`): -/// 同样只推 minor、不强制迁移——[`super::document`] 的「默认值不落盘」让老文件缺键即取默认, -/// 迁移链(按 major 走)不需要新步骤;`1.1.0 → 1.2.0` 的差异只体现在文件里出现新键之后。 +/// 只推 minor、不强制迁移——[`super::document`] 的「默认值不落盘」让老文件缺键即取默认。 /// `1.3.0` 登记 D17 的「外观(高级)」P1 键(`appearance.bg_image*` / `panel_alpha` / `glass` / -/// `accent_color`):同一套纯增量语义——出厂全默认(高级特性全关)即等于老文件缺键。 -/// `1.4.0` 登记 P4 的外观下层三键(`window_opacity` / `titlebar_mode` / `palette_source`,M91): -/// 仍然只推 minor、不加迁移步骤——三者的出厂值正是「老文件没有这些键」时的等价物。 -/// `1.5.0` 登记 M116 的窗口圆角策略(`appearance.window_corner`,出厂 `system`):纯增量。 -/// `1.6.0` 登记 M161 的「自更新」两键(见 [`update`]):纯增量但带一步数据迁移 -/// (此前住在 `cache/update/prefs.json`,迁移见 [`super::update_prefs`])。 +/// `accent_color`)与 `1.5.0` 的窗口圆角、`1.4.0` 的外观下层三键(M91):都是纯增量—— +/// 出厂全默认即等于老文件缺键。 +/// `1.6.0` 登记 M161 的「自更新」两键(见 [`update`]):纯增量 + 一步迁移(见 [`super::update_prefs`])。 /// `1.7.0` 登记 M168 的中文字体下载地址覆盖键(`ui.font_cdn`,见 [`font`]):纯增量、无迁移。 /// /// **旧读新(方向相反,措辞必须精确)**:1.4.0 的客户端读 1.5.0 的文件时, /// [`super::schema::SchemaCompat::check_url`] 走 `found.minor > expected.minor` → -/// `ReadOnlyPreserveSchema` → [`super::schema::SchemaCompat::access`] → `FileAccess::ReadOnly`。 -/// 也就是说**整份配置对它只读**(值照读、`store.set` 一律 `SettingError::ReadOnly`、`save` 跳过), -/// 不是「只是少一档」。这是 minor 提升的既定代价——也正是新键必须推 minor 而不是偷偷塞进旧版本的 -/// 原因:回退到旧版本会出现「设置改不动」,而原因只在 `$schema` 那一行里,不看版本号无从排查。 +/// `ReadOnlyPreserveSchema` → `FileAccess::ReadOnly`:**整份配置对它只读**(值照读、`store.set` +/// 一律 `SettingError::ReadOnly`),不是「只是少一档」。这正是新键必须推 minor 的原因—— +/// 回退到旧版本会出现「设置改不动」,而原因只在 `$schema` 那一行里。 #[must_use] pub fn launcher_version() -> SchemaVersion { SchemaVersion::new(1, 7, 0) @@ -102,19 +100,21 @@ pub fn launcher_schema() -> SettingSchema { AiPolicy::sensitive(), "实例默认隔离模式:isolated(运行目录派生 versions/<版本 id>,D20 默认)/ shared(所有实例共用 .minecraft 根)", )) + // M193:默认值/区间只有一个来源——`download::config` 的常量。此前这里写 8、 + // `EngineConfig::default()` 写 64,界面说「当前 8」引擎却跑 64,调并发完全无效。 .with(SettingSpec::int( "download.concurrency", - 8, - (1, 256), + download::DEFAULT_MAX_CONCURRENCY as i64, + (1, download::MAX_CONCURRENCY as i64), AiPolicy::silent(), - "下载并发数", + "同时下载的文件数(1~64,出厂 8)。这是引擎真实持有的并发许可,不是展示值;越界或手改坏值按出厂值生效", )) .with(SettingSpec::int( "download.retry_limit", - 3, - (0, 10), + download::DEFAULT_MAX_ATTEMPTS_PER_SOURCE as i64, + (1, download::MAX_ATTEMPTS_PER_SOURCE as i64), AiPolicy::silent(), - "单个文件重试次数", + "单个下载源的最多尝试次数(含首次,1 = 不重试;出厂 3)。换源不受此限:候选链上有几个源就各给几次", )) .with(SettingSpec::enumeration( "download.region", diff --git a/crates/core/tests/download/mirrors_test.rs b/crates/core/tests/download/mirrors_test.rs index 61c0696..c5eceeb 100644 --- a/crates/core/tests/download/mirrors_test.rs +++ b/crates/core/tests/download/mirrors_test.rs @@ -122,8 +122,13 @@ fn pinned_or_disabled_gh_proxy_is_respected() { .expect("自定义前缀未生效"); assert_eq!(proxy.url, format!("https://proxy.example/{GITHUB_URL}")); - // 清单给设置做默认值。 - assert_eq!(table.default_gh_proxy(), Some("https://gh-proxy.com/")); + // 「默认前缀」的唯一真相源是**地区档案**(M193:`MirrorTable::set_gh_proxy` / + // `default_gh_proxy` 已删除——它们改/读的是表里的前缀目录,而 `expand()` 读档案, + // 属于「看起来能改、其实没人读」。这条断言从此钉在档案上)。 + assert_eq!( + RegionProfile::mirror_first(0).gh_proxy.as_deref(), + Some(unlucky_core::download::region::DEFAULT_GH_PROXY) + ); } #[test] diff --git a/crates/presenter/src/host/dispatch.rs b/crates/presenter/src/host/dispatch.rs index 1bfc4e0..ccdf8e9 100644 --- a/crates/presenter/src/host/dispatch.rs +++ b/crates/presenter/src/host/dispatch.rs @@ -226,6 +226,11 @@ impl CoreHost { if key == super::engine::DETECT_REGION_ACTION { self.refresh_update_factory(); } + // M193:下载三键(并发/重试/gh-proxy)改了要立刻换引擎参数。这是**显式**的同步点 + // ——不藏在任何读函数里。值没变时它一行不动。 + if settings::download::apply_tuning(&self.env) { + self.refresh_update_factory(); + } self.send(CoreEvent::SettingsChanged(settings::groups(&self.env))); self.java_chain.clear(); self.refresh_java(); diff --git a/crates/presenter/src/host/engine.rs b/crates/presenter/src/host/engine.rs index 3dc1e04..13ab99f 100644 --- a/crates/presenter/src/host/engine.rs +++ b/crates/presenter/src/host/engine.rs @@ -22,8 +22,14 @@ use super::env::HostEnv; /// 取该数据根上**共享**的下载引擎(缓存/健康度/地区判定都在数据根里)。 /// /// 同一个数据根无论被请求多少次,都是同一个引擎;不同数据根各一份(缓存/健康度不串味)。 +/// +/// **取引擎前先把下载设置灌进 core 的下载调优**(M193,按数据根存):`download.concurrency` / +/// `download.retry_limit` / `download.gh_proxy` 变了就重建引擎(值没变时 [`settings::download::apply_tuning`] +/// 一行不动,所以每次调用都灌是安全的)。这是「用户改了设置 ⇒ 下一次下载真的按新值跑」的落点: +/// 不在这里灌,进程级引擎会带着启动时那份参数活到进程结束——那正是「设置页看着有、实际没用」。 #[must_use] pub fn engine(env: &HostEnv) -> Arc { + super::settings::download::apply_tuning(env); shared::engine_for(env.layout.cache()) } @@ -120,7 +126,11 @@ mod tests { let dir = root("cache"); let env = HostEnv::at_root(&dir); let engine = engine(&env); - assert_eq!(engine.config().max_concurrency, 64); + assert_eq!( + engine.config().max_concurrency, + unlucky_core::download::config::DEFAULT_MAX_CONCURRENCY + ); + assert_eq!(engine.concurrency_probe(), engine.config().max_concurrency); // 地区判定不联网:只读缓存/信号 let _ = domestic(&env); std::fs::remove_dir_all(&dir).ok(); diff --git a/crates/presenter/src/host/settings.rs b/crates/presenter/src/host/settings.rs index 572c78e..6c773fb 100644 --- a/crates/presenter/src/host/settings.rs +++ b/crates/presenter/src/host/settings.rs @@ -13,10 +13,14 @@ use crate::dto::{SettingGroupView, SettingRowView}; use super::env::HostEnv; +// M193:下载组的行与「设置 → 引擎调优」的翻译搬进子模块(`settings.rs` 顶在 400 行硬线上, +// 理由与 `settings_tests.rs` 同源,见那个文件的文件头)。 +pub(crate) mod download; + /// 全部设置分组(设置页右栏一次拿全,切分组不再请求)。 pub fn groups(env: &HostEnv) -> Vec { vec![ - download_group(env), + download::group(env), java_group(env), data_group(env), ai_group(env), @@ -58,89 +62,6 @@ fn selected(options: &[&str], value: &str) -> i32 { .unwrap_or(0) } -/// 下载(D7):并发/重试/地区/镜像偏好/代理。 -fn download_group(env: &HostEnv) -> SettingGroupView { - let concurrency = int(env, "download.concurrency", 8).to_string(); - let retry = int(env, "download.retry_limit", 3).to_string(); - let concurrency_options = ["4", "8", "16"]; - let retry_options = ["0", "1", "3", "5"]; - let regions = ["auto", "cn", "global"]; - let mirrors = ["auto", "official", "mirror"]; - let proxies = ["none", "system", "manual"]; - let region_value = text(env, "download.region"); - let mirror_value = text(env, "download.mirror_preference"); - let proxy_value = text(env, "download.proxy_mode"); - SettingGroupView::new("download", "下载与镜像(D7)") - .with(SettingRowView::choice( - "download.concurrency", - "并发数", - "同时下载的文件数(海量小文件,16 路足够压满带宽)", - &concurrency_options, - selected(&concurrency_options, &concurrency), - )) - .with(SettingRowView::choice( - "download.retry_limit", - "单文件重试次数", - "失败后换源重试的上限", - &retry_options, - selected(&retry_options, &retry), - )) - .with(SettingRowView::choice( - "download.region", - "地区判定", - "auto = 时区+语言+探测;决定是否优先国内镜像", - ®ions, - selected( - ®ions, - if region_value.is_empty() { - "auto" - } else { - ®ion_value - }, - ), - )) - .with(SettingRowView::choice( - "download.mirror_preference", - "镜像偏好", - "auto = 按健康度评分;official = 官方源优先", - &mirrors, - selected( - &mirrors, - if mirror_value.is_empty() { - "auto" - } else { - &mirror_value - }, - ), - )) - .with(SettingRowView::choice( - "download.proxy_mode", - "代理模式", - "none / system(跟随系统)/ manual(下面填地址)", - &proxies, - selected( - &proxies, - if proxy_value.is_empty() { - "none" - } else { - &proxy_value - }, - ), - )) - .with(SettingRowView::text( - "download.proxy_url", - "HTTP 代理", - "manual 模式生效;留空则用系统代理", - &text(env, "download.proxy_url"), - )) - .with(SettingRowView::action( - super::engine::DETECT_REGION_ACTION, - "重新检测地区", - "清掉 24h 地区缓存,下次下载重新判定", - "detect_region", - )) -} - /// Java(D13/D23):自动安装/内存/选路/发行版优先级。 fn java_group(env: &HostEnv) -> SettingGroupView { let memory = int(env, "java.default_memory_mb", 2048).to_string(); diff --git a/crates/presenter/src/host/settings/download.rs b/crates/presenter/src/host/settings/download.rs new file mode 100644 index 0000000..22c0320 --- /dev/null +++ b/crates/presenter/src/host/settings/download.rs @@ -0,0 +1,174 @@ +//! 设置页「下载」分组 + 下载调优的**宿主侧翻译**(M193)。 +//! +//! ## 为什么单开一个文件 +//! +//! 同 `host/settings_tests.rs` 的理由:`host/settings.rs` 正好顶在 400 行硬线上,而本轮要往这一组加 +//! 「`download.gh_proxy` 三态行 + 并发硬上限说明 + 调优翻译」约 24 行。不拆就得砍 `settings.rs` 里 +//! 那些**留着防复发**的论证注释(`ai_group` 的 M174 回归说明、`parse_value` 的 P1-1 浮点键事故) +//! ——那比多一个文件糟得多。搬出来还有一个附带好处:这一组是**唯一**要往 core 灌下载调优的一组, +//! 代码与它的守卫(`settings_tests.rs`)现在挨在一起。 +//! +//! ## 谁在读 / 谁在写 +//! +//! ```text +//! config/launcher.json ←── store.set(`settings::set`,本模块只读) +//! download.concurrency ─┐ +//! download.retry_limit ─┼─→ DownloadTuning(core 决定默认值与夹取)─→ shared::set_tuning +//! download.gh_proxy ───┘ (引擎重建时生效) +//! ``` +//! +//! **调优的同步点是显式的**:`host/engine.rs` 每次取引擎前、`dispatch.rs` 的 `SetSetting` 分支里, +//! 都调 [`apply_tuning`](幂等:值没变就什么都不做)。它**不是**藏在某个读函数里的副作用—— +//! 「读起来是纯读、实际改全局状态」正是本仓吃过亏的那类形状。 + +use unlucky_core::download::config::{ + DownloadTuning, DEFAULT_MAX_ATTEMPTS_PER_SOURCE, DEFAULT_MAX_CONCURRENCY, +}; +use unlucky_core::download::shared; + +use crate::dto::{SettingGroupView, SettingRowView}; + +use super::super::env::HostEnv; +use super::{int, selected, text}; + +/// 并发数的推荐档位(`selected()` 找不到时回 0;手改出来的非档位值会被插进选项表,见 [`group`])。 +const CONCURRENCY_LADDER: [&str; 5] = ["4", "8", "16", "32", "64"]; +/// 单源尝试次数的推荐档位。 +const RETRY_LADDER: [&str; 5] = ["1", "2", "3", "5", "10"]; + +/// 下载(D7):并发/重试/地区/镜像偏好/代理/GitHub 加速前缀。 +pub(super) fn group(env: &HostEnv) -> SettingGroupView { + let concurrency = int( + env, + unlucky_core::download::config::KEY_CONCURRENCY, + DEFAULT_MAX_CONCURRENCY as i64, + ) + .to_string(); + let retry = int( + env, + unlucky_core::download::config::KEY_RETRY_LIMIT, + i64::from(DEFAULT_MAX_ATTEMPTS_PER_SOURCE), + ) + .to_string(); + let concurrency_options = ladder(&CONCURRENCY_LADDER, &concurrency); + let retry_options = ladder(&RETRY_LADDER, &retry); + let concurrency_options: Vec<&str> = concurrency_options.iter().map(String::as_str).collect(); + let retry_options: Vec<&str> = retry_options.iter().map(String::as_str).collect(); + let regions = ["auto", "cn", "global"]; + let mirrors = ["auto", "official", "mirror"]; + let proxies = ["none", "system", "manual"]; + let region_value = text(env, "download.region"); + let mirror_value = text(env, "download.mirror_preference"); + let proxy_value = text(env, "download.proxy_mode"); + SettingGroupView::new("download", "下载与镜像(D7)") + .with(SettingRowView::choice( + unlucky_core::download::config::KEY_CONCURRENCY, + "并发数", + "同时下载的文件数:出厂 8,可调 1~64(引擎硬上限 64,防止填 9999 把机器拖死);越界或手改坏值按出厂值生效", + &concurrency_options, + selected(&concurrency_options, &concurrency), + )) + .with(SettingRowView::choice( + unlucky_core::download::config::KEY_RETRY_LIMIT, + "单源尝试次数", + "同一个源最多试几次(含首次;出厂 3)。换源不受此限:候选链上有几个源就各给几次", + &retry_options, + selected(&retry_options, &retry), + )) + .with(SettingRowView::choice( + "download.region", + "地区判定", + "auto = 时区+语言+探测;决定是否优先国内镜像", + ®ions, + selected( + ®ions, + if region_value.is_empty() { + "auto" + } else { + ®ion_value + }, + ), + )) + .with(SettingRowView::choice( + "download.mirror_preference", + "镜像偏好", + "auto = 按健康度评分;official = 官方源优先", + &mirrors, + selected( + &mirrors, + if mirror_value.is_empty() { + "auto" + } else { + &mirror_value + }, + ), + )) + .with(SettingRowView::choice( + "download.proxy_mode", + "代理模式", + "none / system(跟随系统)/ manual(下面填地址)", + &proxies, + selected( + &proxies, + if proxy_value.is_empty() { + "none" + } else { + &proxy_value + }, + ), + )) + .with(SettingRowView::text( + "download.proxy_url", + "HTTP 代理", + "manual 模式生效;留空则用系统代理", + &text(env, "download.proxy_url"), + )) + // M193:gh-proxy 三态。**留空与 off 是两件事**,描述里必须都写出来——用户最容易把 + // 「我还没决定」当成「我不要加速」,于是以为自己关掉了、实际还在套前缀。 + .with(SettingRowView::text( + unlucky_core::download::config::KEY_GH_PROXY, + "GitHub 加速前缀(gh-proxy)", + "留空 = 跟随内置默认(镜像优先/国内判定时自动套 gh-proxy.com);填 off = 关闭(不生成任何 gh-proxy 候选);填一个 URL 前缀 = 覆盖它", + &text(env, unlucky_core::download::config::KEY_GH_PROXY), + )) + .with(SettingRowView::action( + super::super::engine::DETECT_REGION_ACTION, + "重新检测地区", + "清掉 24h 地区缓存,下次下载重新判定", + "detect_region", + )) +} + +/// 推荐档位 + 「当前值不在档位里就把它插进去」(否则这一行会把 2 显示成 4 —— 又一个假值)。 +fn ladder(ladder: &[&str], current: &str) -> Vec { + let mut options: Vec = ladder.iter().map(|item| (*item).to_string()).collect(); + if !current.is_empty() && !options.iter().any(|option| option == current) { + options.push(current.to_string()); + options.sort_by_key(|option| option.parse::().unwrap_or(i64::MAX)); + } + options +} + +/// 从 `launcher.json` 读出下载调优(默认值与夹取口径在 core,本函数只做「取原始值」)。 +#[must_use] +pub(crate) fn tuning_of(env: &HostEnv) -> DownloadTuning { + use unlucky_core::download::config::{KEY_CONCURRENCY, KEY_GH_PROXY, KEY_RETRY_LIMIT}; + let store = env.store(); + let doc = store.doc(); + let gh = doc + .effective_str(KEY_GH_PROXY) + .map(str::to_string) + .unwrap_or_default(); + DownloadTuning::from_settings( + doc.effective_i64(KEY_CONCURRENCY), + doc.effective_i64(KEY_RETRY_LIMIT), + Some(gh.as_str()), + ) +} + +/// 把当前设置灌进 core 的下载调优(按数据根),返回**是否真的变了**(变了就失效该根的引擎)。 +/// +/// 幂等:值没变时 `shared::set_tuning` 一行不动,所以「每次取引擎前都调」是安全的。 +pub(crate) fn apply_tuning(env: &HostEnv) -> bool { + shared::set_tuning(env.layout.cache(), tuning_of(env)) +} diff --git a/crates/presenter/src/host/settings_tests.rs b/crates/presenter/src/host/settings_tests.rs index 0cabb39..480747a 100644 --- a/crates/presenter/src/host/settings_tests.rs +++ b/crates/presenter/src/host/settings_tests.rs @@ -1,4 +1,8 @@ //! host/settings.rs 的单测(拆文件守 400 行上限)。 +//! +//! **为什么单独一个文件**:`settings.rs` 正好顶在 400 行硬线上,而「设置写进去到底有没有生效」 +//! 这一类用例恰恰需要反复构造数据根 + 读写 `launcher.json`,塞回生产文件就只剩砍论证注释这一条路。 +//! 本文件与它守的代码同目录、同名族(`settings.rs` 的 `#[path]` 挂载),**不是**随手放的杂项目。 use super::*; @@ -7,6 +11,51 @@ fn env_in(dir: &std::path::Path) -> HostEnv { HostEnv::at_root(dir) } +/// **M193 守门**:把设置改成 2 ⇒ **下一次取到的引擎**的并发闸门真是 2(探针读 `Semaphore`)。 +/// +/// 这是「设置项说假话」那条缺陷族的正面守门。断言分三段,缺一段都守不住: +/// +/// 1. 写设置(走 `SetSetting` 同一条 `settings::set`); +/// 2. 走宿主真正的取引擎入口 `host::engine::engine(env)`(它负责把设置灌进 core 调优); +/// 3. 读**引擎的真实闸门** `concurrency_probe()`,不是 `config().max_concurrency`。 +/// +/// 变异证明(只改实现侧): +/// * 把 `host/engine.rs::engine` 里那行 `apply_tuning` 删掉 ⇒ 引擎按出厂 8 建,探针读到 8,红; +/// * 把 `core::download::shared::build` 里的 `tune.apply_to(EngineConfig::default())` 换回 +/// `EngineConfig::default()` ⇒ 同样读到 8,红; +/// * 把 `settings/download.rs::tuning_of` 读错键名(例如 `download.threads`)⇒ 读到默认 8,红。 +/// +/// `download.gh_proxy` 那一路**没有**端到端用例:那个键由 M197 登记,尚未合入 dev,而 +/// `setting::document` 只认已登记的键(未登记的键进不了 `effective()`),所以这里写进文件也读不出来。 +/// core 侧有「调优三档 → 候选链」的守卫(`download/shared.rs`),端到端那一半登记为 M197 的 follow-up。 +#[test] +fn the_concurrency_setting_reaches_the_real_download_engine() { + let dir = std::env::temp_dir().join(format!("ull-host-tune-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let env = env_in(&dir); + + set(&env, "download.concurrency", "2").expect("写并发"); + set(&env, "download.retry_limit", "1").expect("写尝试次数"); + + let engine = crate::host::engine::engine(&env); + assert_eq!(engine.concurrency_probe(), 2, "闸门必须真是 2 张许可"); + assert_eq!( + engine.config().max_attempts_per_source, + 1, + "尝试次数也要吃到设置" + ); + + // 改回去 ⇒ 下一次取到的引擎跟着换(不是「重启才生效」)。 + set(&env, "download.concurrency", "16").expect("再写并发"); + let rebuilt = crate::host::engine::engine(&env); + assert_eq!(rebuilt.concurrency_probe(), 16); + assert!( + !std::sync::Arc::ptr_eq(&engine, &rebuilt), + "参数变了必须换引擎,否则新值落不到在飞的那份上" + ); + std::fs::remove_dir_all(&dir).ok(); +} + #[test] fn groups_cover_the_six_required_areas() { let dir = std::env::temp_dir().join(format!("ull-host-settings-{}", std::process::id())); diff --git a/docs/download-settings.md b/docs/download-settings.md new file mode 100644 index 0000000..c52972b --- /dev/null +++ b/docs/download-settings.md @@ -0,0 +1,119 @@ +# 下载设置到底改了谁(M193) + +一句话:**`download.concurrency` / `download.retry_limit` / `download.gh_proxy` 现在真的进引擎**; +其余四个 `download.*` 键(`region` / `mirror_preference` / `proxy_mode` / `proxy_url`)**引擎侧零消费者**, +界面上改了不会改变任何下载行为——这一页把它们逐条标出来,别再让「设置项说假话」。 + +出处:M190 的 204 条 finding 复核(`DevDocs/PlanDoc/research/191-finding-triage.md` §3 A-3)挖出的 +「用户能改、改了没用」缺口:schema 说并发 8,引擎恒跑 64,两处各写一个数。 + +## 1. 取证:八个下载类键的真实生效值 + +「真实生效值」是**改造前**(M193 之前)代码里实际会用的值,不是界面上写的值。 + +| 键 | schema 默认 / 范围 | 引擎侧消费者 | 真实生效值(改造前) | 本轮 | +|---|---|---|---|---| +| `download.concurrency` | 8 / 1–256 | **无**(恒用 `EngineConfig::default()`) | **64**(`Semaphore` 容量) | ✅ 接进引擎,范围收成 1–64 | +| `download.retry_limit` | 3 / 0–10 | **无** | 3(碰巧与 schema 相同,但改它无效) | ✅ 接进引擎,范围 (1,10) | +| `download.gh_proxy` | (M197 登记,dev 上尚无此键) | JDK 作业那条(M197 自建引擎时覆盖档案) | 地区档案里的内置值(镜像优先时 `gh-proxy.com`) | ✅ 下沉到共享装配点,所有下载路径一致 | +| `download.region` | auto / auto·cn·global | **无**(`RegionProfile::pinned` 无生产调用点) | 永远按「时区+语言」自动判定 | ❌ 未接(见 §5) | +| `download.mirror_preference` | auto / auto·official·mirror | **无** | 永远按地区模式(镜像优先/直连) | ❌ 未接(见 §5) | +| `download.proxy_mode` | none / none·system·manual | **无**(`http::build_client` 从不调 `.proxy()`) | 只走环境变量代理(reqwest 默认),`system`/`manual` 都不生效 | ❌ 未接(见 §5) | +| `download.proxy_url` | "" | **无** | 空(从不使用) | ❌ 未接(见 §5) | +| `download.action.detect_region` | (动作键,不是设置) | presenter 的 `detect_region` | 清 `region.json` + 失效共享引擎 | ✅ 本来就真 | + +复现方法(改造前):`grep -rn "download.concurrency" crates/` 只命中 `setting/keys.rs` 的定义与 +agent 的测试;同理 `download.proxy_mode` / `proxy_url` / `mirror_preference` 只有 presenter 的显示行。 + +## 2. 唯一来源:默认值谁说了算 + +```text +crates/core/src/download/config.rs + DEFAULT_MAX_CONCURRENCY = 8 ← 唯一写死数字的地方 + MAX_CONCURRENCY = 64 ← 硬上限 + DEFAULT_MAX_ATTEMPTS_PER_SOURCE = 3 + MAX_ATTEMPTS_PER_SOURCE = 10 + │ + ├─→ EngineConfig::default() (引擎默认) + ├─→ DownloadTuning::default() (宿主没灌任何值时) + └─→ setting::keys 的 schema 默认值与范围 (界面/AI/CLI 看到的契约) +``` + +守卫 `the_schema_defaults_are_the_engine_defaults`(`download/config.rs`)与 +`the_hard_caps_are_single_sourced` 让「改一处另一个不变」直接变红——这是防 8 vs 64 复发的唯一手段。 + +## 3. 接线链(宿主 → 引擎) + +```text +config/launcher.json + download.concurrency ─┐ + download.retry_limit ─┼→ presenter::host::settings::download::tuning_of + download.gh_proxy ────┘ │ DownloadTuning::from_settings(默认值/夹取/回落都在 core) + ↓ + shared::set_tuning(, tuning) 变了吗?没变就什么都不做 + ↓ 变了 → 淘汰该根的登记项 + shared::engine_for() → shared::build + ↓ + EngineConfig { max_concurrency, max_attempts_per_source } + RegionProfile.gh_proxy + ↓ + Semaphore(容量) / attempts_for() / MirrorTable::expand() 的 gh-proxy 候选 +``` + +**同步点是显式的**,两处,共用同一个函数 `apply_tuning`: + +* `presenter/src/host/engine.rs::engine()` —— 每次取引擎前(幂等,值没变就一行不动); +* `presenter/src/host/dispatch.rs` 的 `SetSetting` 分支 —— 用户改完设置立刻生效,并让常驻持有 + 引擎的自更新 `Factory` 换一份(`refresh_update_factory`)。 + +调优按**数据根**存(与引擎登记处同键),不是进程级:同一进程里多个数据根时互不串味。 + +## 4. 三态语义:`download.gh_proxy` + +| 值 | 含义 | 效果 | +|---|---|---| +| 空串(出厂) | **没有覆盖**,跟随内置默认 | 镜像优先/国内判定时套 `mirrors.json` 里的 `gh-proxy.com` | +| `off`(也认 `none`/`direct`/`disable`/`disabled`/`no`) | 明确关掉 | 一个 gh-proxy 候选都不生成 | +| 其它非空 | 覆盖前缀 | 用它替换内置前缀(类别仍只在镜像优先/国内判定时启用) | + +**空串 ≠ 关**:「我还没决定」和「我明确不要」是两件事。设置页的说明文案(presenter 行 + iced 行) +两档必须都写出来——这是最容易让用户以为「关掉了、其实还在套前缀」的地方。 + +边界:非法值(0、负数、超上限、手改坏值)**一律回落出厂默认**,不报错、不阻断下载; +schema 在写入口就拒绝越界值,引擎侧再夹一次是纵深防御(老文件里可能躺着旧 schema 允许的大数)。 + +## 5. 没接的四个键(已登记为 finding) + +`download.region` / `download.mirror_preference` / `download.proxy_mode` / `download.proxy_url` +**引擎侧零消费者**:界面上改了不改变任何下载行为。要接它们分别需要: + +* `region` → `RegionProfile::pinned(mode, now)`(值 `cn`/`global` 映射到 `MirrorFirst`/`Direct`, + 并把 `trigger` 置成 `UserPinned` 让 24h 重判不覆盖它); +* `mirror_preference` → 引擎候选排序里插一档「强制官方优先 / 强制镜像优先」; +* `proxy_mode` / `proxy_url` → `http::build_client` 里加 `.proxy(reqwest::Proxy::all(url)?)`。 + +三条都在 M193 的 scope 外(`http.rs` / 引擎候选排序 / 一处新装配),已单独立 finding。 + +## 6. 守卫与变异证明(逐条只改实现侧,跑完即还原) + +| # | 变异(只改实现侧) | 守卫用例 | 结果 | +|---|---|---|---| +| M1 | `shared::build` 的 `tune.apply_to(...)` 换回裸 `EngineConfig::default()` | `download::shared::tests::the_setting_and_the_gh_proxy…` | 红(探针 8,期望 2) | +| M2 | `shared::build` 的 `tune.apply_gh_proxy(region)` 换回 `region` | 同上 | 红(`off` 档仍有 gh-proxy 候选) | +| M3 | 删掉 `set_tuning` 里的 `registry().remove` | 同上 | 红(换参数后交回旧引擎) | +| M4 | 删掉构造点的 `clamp(1, MAX_CONCURRENCY)` | 同上 | 红(9999 张许可真开了) | +| M5 | `EngineConfig::default()` 的并发写回字面量 64 | `download::config::tests::the_schema_defaults…` | 红 | +| M6 | schema 的并发默认值写成字面量 16 | 同上 | 红(两处各写一个数) | +| M7 | 删掉 `host/engine.rs::engine` 里的 `apply_tuning` | `host::settings::tests::the_concurrency_setting…` | 红(探针 8,期望 2) | +| M8 | 下载设置页的 gh-proxy 行改回 `("Preferred source", "Automatic (region)")` | `the_downloads_rows_state_semantics…` | 红 | + +## 7. 没验的部分 + +* **真实网络下的并发/重试行为未验**:守卫读的是 `Semaphore` 的真实许可数与 `attempts_for()` 的 + 配置读点;「2 条真连接同时下载时服务器只看到 2 条」和「某个源真的被试 3 次」没有在真网络下跑过 + (离线回环能验连接复用,验不了限流的端到端时序)。 +* **macOS / Linux 未验**:本轮只在 Windows 上编译与跑测试。 +* **`download.gh_proxy` 的端到端未验**:那个键由 M197 登记,尚未合入 dev;`setting::document` 只认 + 已登记的键,所以「写文件 ⇒ 读出来 ⇒ 进候选链」这一段在 dev 上跑不通(core 侧「调优 → 候选链」 + 已守)。M197 合入后应补一条宿主侧端到端用例。 +* **iced 设置页仍然不是可改控件**:它画的是语义行(默认值/区间/三态),真值不显示——因为 iced 侧 + 还没有「presenter 设置快照 → app → `Groups.download`」的数据通路(另立 mission,见 finding)。 diff --git a/docs/images/downloads-settings-zh.png b/docs/images/downloads-settings-zh.png new file mode 100644 index 0000000000000000000000000000000000000000..a9ecf73c5535ed7e47fa3be2d2b3727e37f0c95b GIT binary patch literal 95845 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])LWLYN=_<_^|oB!?B-9u|}%6%5i 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 SOU}VR3*TP+*3maV+VJ1FFQF3v literal 0 HcmV?d00001