diff --git a/crates/cli/src/download.rs b/crates/cli/src/download.rs index 050395f..30c0cbc 100644 --- a/crates/cli/src/download.rs +++ b/crates/cli/src/download.rs @@ -11,16 +11,22 @@ //! ```text //! <数据根>/config/launcher.json(与 `ull config set` 同一份) → setting store → DownloadTuning //! (默认值/夹取/回落在 core) -//! → shared::engine_with_tuning(, tuning) → 引擎的 Semaphore / attempts_for() +//! → shared::engine_with_tuning(, tuning) → 引擎的 Semaphore / attempts_for() / 三段超时 //! ``` //! +//! M292:`download.*` 从三个键扩到六个(多了吞吐下限 / 无进展窗口 / 单次请求上界),装配链没变, +//! 变的是 [`tuning`] 取值的宽度与 [`engine`] 里那一行**仅在改过设置时**才打的 stderr 说明——用户在 +//! 慢链路上改完设置,得能在终端里看见引擎实际拿到了什么(P21 的验收判据;为什么不是写 +//! `logs/launcher.log` 见 [`announce`] 的文档)。 +//! //! **回落是静默的**:`SettingStore::load` 永不失败(坏值只进 `problems()`);类型不对 → `None`, -//! 越界 → `DownloadTuning::from_settings` 的区间过滤。两种情况都落成出厂值,**不报错、不中断安装**。 +//! 越界 → `DownloadTuning::from_raw` 的区间过滤。两种情况都落成出厂值,**不报错、不中断安装**。 use std::sync::Arc; use unlucky_core::download::config::{ - DownloadTuning, KEY_CONCURRENCY, KEY_GH_PROXY, KEY_RETRY_LIMIT, + DownloadTuning, RawTuning, KEY_CHUNK_STALL, KEY_CONCURRENCY, KEY_GH_PROXY, + KEY_RESPONSE_TIMEOUT, KEY_RETRY_LIMIT, KEY_THROUGHPUT_FLOOR, }; use unlucky_core::download::{shared, DownloadEngine}; use unlucky_core::setting::keys::launcher_schema; @@ -28,10 +34,14 @@ use unlucky_core::setting::store::SettingStore; use crate::context::Ctx; -/// 从 CLI 自己的 setting store 读下载调优(`launcher.json` 的三个 `download.*` 键)。 +/// 从 CLI 自己的 setting store 读下载调优(`launcher.json` 的六个 `download.*` 键)。 /// /// 读的是 `ctx.launcher_config()`——与 `ull config set download.concurrency` 的落点同一份文件 /// (设置键的落点分流见 [`Ctx::setting_path_for`])。gh-proxy 空串 = 跟随内置默认,**不是**关。 +/// +/// **本函数只做「取裸值」**:默认值、区间、回落、KiB→字节的换算全在 core 的 +/// [`DownloadTuning::from_raw`](M292 起宿主不再逐个判断,也不再自己决定缺省值——那是「UI 说 8、 +/// 引擎跑 64」长回来的地方)。 #[must_use] pub(crate) fn tuning(ctx: &Ctx) -> DownloadTuning { let store = SettingStore::load(ctx.launcher_config(), launcher_schema()); @@ -40,17 +50,42 @@ pub(crate) fn tuning(ctx: &Ctx) -> DownloadTuning { .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()), - ) + DownloadTuning::from_raw(RawTuning { + concurrency: doc.effective_i64(KEY_CONCURRENCY), + retry_limit: doc.effective_i64(KEY_RETRY_LIMIT), + gh_proxy: Some(gh_proxy.as_str()), + throughput_floor_kib: doc.effective_i64(KEY_THROUGHPUT_FLOOR), + stall_timeout_secs: doc.effective_i64(KEY_CHUNK_STALL), + response_timeout_secs: doc.effective_i64(KEY_RESPONSE_TIMEOUT), + }) +} + +/// 生效值那一行说明,**只在改过设置时**给:`None` = 全是出厂值,没什么可说的。 +/// +/// 为什么是 stderr 而不是 `logs/launcher.log`:那个日志的目录是「先到者登记」的进程级全局 +/// (`launcher_log::set_dir`),在下载装配点调它等于让一个读路径改全局状态——本仓被这种形状咬过 +/// (`host/settings/download.rs` 的模块头记着:`apply_tuning` 的同步点是**显式**的,不是藏在读 +/// 函数里)。CLI 的 stderr 是这一行的天然去处:`--json` 的机器输出在 stdout,不会被它污染。 +#[must_use] +pub(crate) fn announce(tuning: &DownloadTuning) -> Option { + if *tuning == DownloadTuning::default() { + None + } else { + Some(format!("下载调优:{}", tuning.summary())) + } } /// 建该数据根的下载引擎:参数来自 [`tuning`],引擎本体由 core 按缓存根共享。 +/// +/// 改过设置时往 stderr 打**一行**生效值(P21 的验收判据:改完设置得能看见引擎实际拿到了什么)。 +/// 出厂值那一档不打:否则每个 `ull install` 都多一行噪声,而它什么也没告诉用户。 #[must_use] pub(crate) fn engine(ctx: &Ctx) -> Arc { - shared::engine_with_tuning(ctx.layout().cache(), tuning(ctx)) + let tuning = tuning(ctx); + if let Some(line) = announce(&tuning) { + eprintln!("{line}"); + } + shared::engine_with_tuning(ctx.layout().cache(), tuning) } #[cfg(test)] diff --git a/crates/cli/src/download/tests.rs b/crates/cli/src/download/tests.rs index 4fe04b5..3460394 100644 --- a/crates/cli/src/download/tests.rs +++ b/crates/cli/src/download/tests.rs @@ -264,3 +264,82 @@ fn missing_or_illegal_values_fall_back_to_the_factory_defaults_silently() { ); } } + +/// **M292 / P21 判据的 CLI 侧闭合**:三个超时键从 `launcher.json` 进到**生产路径**的引擎配置,而且 +/// 改过设置时终端上那行说明写的是**生效值**。 +/// +/// 为什么断言 [`super::announce`]:台账原文的判据是「改完能在 `--dry-run` 或日志里看到生效值」。 +/// CLI 没有打印调优的 `--dry-run`(那几处 `--dry-run` 只出计划清单 / 版本串),所以这一行走 +/// stderr,而它的内容由 `announce` 产出——**断言这个函数就是断言终端上那行**(不是另拼一份旁证)。 +/// 走 stderr 而不是 `logs/launcher.log` 的理由见 `announce` 的文档:那个日志的目录是进程级全局, +/// 在装配点调 `set_dir` 会让一个读路径改全局状态(M292 第一版就是这么写的,`commands::sessions` +/// 的 launcher.log 用例当场红了)。 +/// +/// 抓得住:① `download::tuning` 少读一个键 ⇒ 引擎字段与那行说明同时红;② `announce` 对出厂值也返回 +/// `Some` ⇒ 第二段红(每个 `ull install` 都多一行噪声);③ 把汇总写成常量 ⇒ 说明里的数字对不上。 +/// 抓不住:真机慢链路上「预算到底够不够」(离线环境没有真链路,见 M292 报告的未验项)。 +#[test] +fn the_timeout_settings_reach_the_engine_and_the_console_line() { + use std::time::Duration; + + let dir = TempDir::new("timeouts"); + let ctx = ctx_at(&dir); + write_launcher_config( + &ctx, + json!({ "download": { + "throughput_floor_kib": 16, + "stall_timeout_secs": 300, + "response_timeout_secs": 180, + }}), + ); + + // 出厂档不该打那一行(否则每个命令都多一行噪声)。 + assert!( + super::announce(&DownloadTuning::default()).is_none(), + "出厂值不该打说明" + ); + + // 生产路径:`ull install` 取引擎的那个入口(四条命令共用这一处装配)。 + let engine = crate::commands::install::build_engine(&ctx); + assert_eq!( + engine.config().min_avg_throughput, + 16 * 1024, + "throughput_floor_kib=16 ⇒ 引擎里是 16 KiB/s(字节/秒)" + ); + assert_eq!( + engine.config().chunk_stall_timeout, + Duration::from_secs(300) + ); + assert_eq!(engine.config().response_timeout, Duration::from_secs(180)); + + // 终端上那行(同一份 `summary()`,同一个 `announce`)。 + let line = super::announce(&super::tuning(&ctx)).expect("改过设置 ⇒ 该有一行说明"); + assert!(line.contains("吞吐下限=16KiB/s"), "{line}"); + assert!(line.contains("无进展窗口=300s"), "{line}"); + assert!(line.contains("单次请求上界=180s"), "{line}"); + + // 不设 ⇒ 引擎回到出厂值,终端也不再打说明(「不设 = 当前行为」的 CLI 侧证据)。 + write_launcher_config(&ctx, json!({ "download": {} })); + let engine = crate::commands::install::build_engine(&ctx); + assert_eq!( + engine.config().response_timeout, + Duration::from_secs(60), + "未设置 ⇒ 出厂 60s" + ); + assert!( + super::announce(&super::tuning(&ctx)).is_none(), + "全出厂值不该打说明" + ); + + // 非法值:三个键一起写成 0 / 越界 ⇒ 回落出厂,且说明行不出现(不是「打一行假的」)。 + write_launcher_config( + &ctx, + json!({ "download": { + "throughput_floor_kib": 0, + "stall_timeout_secs": 0, + "response_timeout_secs": 99_999, + }}), + ); + assert_eq!(super::tuning(&ctx), DownloadTuning::default()); + assert!(super::announce(&super::tuning(&ctx)).is_none()); +} diff --git a/crates/core/src/download/config.rs b/crates/core/src/download/config.rs index 0f67ccb..5baa656 100644 --- a/crates/core/src/download/config.rs +++ b/crates/core/src/download/config.rs @@ -1,10 +1,18 @@ //! 引擎配置:设置层(core::setting)直接映射到这里。 //! -//! # 出厂默认值与设置 schema 的唯一来源(M193) +//! # 出厂默认值与设置 schema 的唯一来源(M193;M292 加上三个超时/阈值常量) //! -//! 四个常量是**唯一**写死数字的地方:schema 的默认值/范围、`EngineConfig::default()`、 -//! `Semaphore` 容量全从它们取。此前 schema 写 8、引擎写 64 ⇒ 调并发完全无效(引擎侧零消费者)。 -//! 收成常量后「改一处另一个不变」会直接变红(守卫见 `shared.rs` 与本文件的用例)。 +//! 这些 `DEFAULT_* / MAX_*` 常量是**唯一**写死数字的地方:schema 的默认值/范围、 +//! `EngineConfig::default()`、`Semaphore` 容量、三个超时键的回落值全从它们取。此前 schema 写 8、 +//! 引擎写 64 ⇒ 调并发完全无效(引擎侧零消费者)。收成常量后「改一处另一个不变」会直接变红 +//! (守卫见 `shared.rs` 与本模块 `tests.rs` 的用例)。 +//! +//! # 本文件与 [`tuning`] 的分工 +//! +//! * 本文件:**引擎要什么**([`EngineConfig`])、数字的出厂值与上下界、gh-proxy 三态、 +//! 按数据根的调优登记处([`wanted`] / [`publish`])。 +//! * [`tuning`]:**设置原文怎么变成这些数**(`DownloadTuning` / `RawTuning` / 回落与夹取)。 +//! 两者拆开只为守全仓 400 行硬线,类型路径没变(下面那行 `pub use`)。 use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -13,10 +21,13 @@ use std::time::Duration; use super::backoff::BackoffPolicy; use super::chunked::ChunkedConfig; -use super::region::RegionProfile; use super::stall::{self, Deadlines}; use super::types::ResumePolicy; +mod tuning; + +pub use tuning::{DownloadTuning, RawTuning}; + /// 全局并发许可数的出厂默认(`download.concurrency` 的 schema 默认值同源)。 pub const DEFAULT_MAX_CONCURRENCY: usize = 8; @@ -30,10 +41,50 @@ pub const DEFAULT_MAX_ATTEMPTS_PER_SOURCE: u32 = 3; /// 每个源尝试次数的硬上限(`download.retry_limit` 的 schema 上界同源)。 pub const MAX_ATTEMPTS_PER_SOURCE: u32 = 10; +/// 分块作业平均吞吐下限的**出厂**值(KiB/s = 32,取自 [`stall::MIN_AVG_THROUGHPUT`])。 +/// +/// `download.throughput_floor_kib` 的 schema 默认值、[`EngineConfig::default`] 与 +/// [`DownloadTuning::default`] 同源;引擎里存的是**字节/秒**,KiB→字节的换算只在 +/// [`DownloadTuning::from_raw`] 一处做。 +pub const DEFAULT_THROUGHPUT_FLOOR_KIB: u32 = (stall::MIN_AVG_THROUGHPUT / 1024) as u32; + +/// 吞吐下限的可设下界(KiB/s)。4 KiB/s 对 188MB 已经是 800 min 的预算——再低就不是「慢」, +/// 而是「这条链路下不完,别继续占着许可」。 +pub const MIN_THROUGHPUT_FLOOR_KIB: u32 = 4; + +/// 吞吐下限的可设上界(KiB/s = 4 MiB/s)。再高会让 188MB 的预算掉到 47s,等于把「慢但有效」 +/// 一刀切死;想更快该换源/换镜像,不是压预算。 +pub const MAX_THROUGHPUT_FLOOR_KIB: u32 = 4096; + +/// 分块作业「已这么久没有任何新字节」的出厂窗口(秒)。 +pub const DEFAULT_CHUNK_STALL: Duration = Duration::from_secs(120); + +/// 无进展窗口的可设下界(秒):比 30s 更短会把正常的调度抖动当成失速。 +pub const MIN_CHUNK_STALL_SECS: u32 = 30; + +/// 无进展窗口的可设上界(秒,10 min)。**这道上界就是「把挂住变成无界」的兜底**:它是 +/// *单次尝试* 的界,最坏总等待还要乘「候选数 × 每源尝试次数」,所以再放宽一档就是几小时起步。 +pub const MAX_CHUNK_STALL_SECS: u32 = 600; + +/// 单次请求(`send()` → 响应头到手)上界的出厂值(秒)。 +pub const DEFAULT_RESPONSE_TIMEOUT: Duration = Duration::from_secs(60); + +/// 等响应头的可设下界(秒)。 +pub const MIN_RESPONSE_TIMEOUT_SECS: u32 = 10; + +/// 等响应头的可设上界(秒,10 min):回源冷启动再慢也不该超过这个量级。 +pub const MAX_RESPONSE_TIMEOUT_SECS: u32 = 600; + /// 三个设置键名(层间契约):登记在 `setting::keys`,读取在宿主。只写在这里,两侧都引用常量。 pub const KEY_CONCURRENCY: &str = "download.concurrency"; /// 见 [`KEY_CONCURRENCY`]。 pub const KEY_RETRY_LIMIT: &str = "download.retry_limit"; +/// 分块作业平均吞吐下限(KiB/s)。语义与「改它要付什么代价」见 [`stall`] 模块头。 +pub const KEY_THROUGHPUT_FLOOR: &str = "download.throughput_floor_kib"; +/// 分块作业无进展窗口(秒)。见 [`KEY_THROUGHPUT_FLOOR`] 的那处文档。 +pub const KEY_CHUNK_STALL: &str = "download.stall_timeout_secs"; +/// 单次请求等响应头的上界(秒)。见 [`KEY_THROUGHPUT_FLOOR`] 的那处文档。 +pub const KEY_RESPONSE_TIMEOUT: &str = "download.response_timeout_secs"; /// gh-proxy 前缀覆盖键(三态语义见 [`gh_proxy_override`])。**已登记**:`setting::keys::proxy` 的 /// `specs()` 里有它(文本 / 出厂空串),presenter 与 CLI 的宿主都按这个常量读,读不到 ⇒ /// [`GhProxyOverride::Builtin`]。**绑定在哪(别当死键删)**:常量与 schema 登记的对应关系由 @@ -52,17 +103,25 @@ pub struct EngineConfig { pub connect_timeout: Duration, /// 单次读超时(读空闲判定,不是整体超时),默认 30s。 pub read_timeout: Duration, - /// 等响应头的上界(**单次请求**:`send()` 到响应头为止,不含正文),默认 60s。 + /// 等响应头的上界(**单次请求**:`send()` 到响应头为止,不含正文),默认 + /// [`DEFAULT_RESPONSE_TIMEOUT`](60s),可被 `download.response_timeout_secs` 改。 /// /// 取数与「为什么不是整作业总预算」见 [`super::stall`] 模块头那一处,别在调用点另写一套理由。 pub response_timeout: Duration, - /// 分块作业「已这么久没有任何新字节」的判定窗口,默认 120s。 + /// 分块作业「已这么久没有任何新字节」的判定窗口,默认 [`DEFAULT_CHUNK_STALL`](120s), + /// 可被 `download.stall_timeout_secs` 改。 /// /// 抓的是「读超时不响、但整个作业不动」的那类卡住(分块任务卡在写盘上)。 pub chunk_stall_timeout: Duration, /// 分块作业总期限的下限,默认 10min(实际期限取它与「按平均吞吐下限反推的时长」的较大者, - /// 见 [`super::stall::job_budget`])。 + /// 见 [`super::stall::job_budget`])。**没有**设置键:小作业的期限不该由用户填。 pub chunk_job_floor: Duration, + /// 分块作业的**平均吞吐下限**(字节/秒),默认 [`stall::MIN_AVG_THROUGHPUT`](32 KiB/s), + /// 可被 `download.throughput_floor_kib` 改(那是 KiB/s 口径)。 + /// + /// 总期限 = `总字节 / 它`;调低它 ⇒ 慢链路能下完、但「真挂住」的作业占用更久。 + /// 代价与上界见 [`super::stall`] 模块头「改这几个数要付的代价」。 + pub min_avg_throughput: u64, /// 整文件重算摘要(校验阶段)的上界,默认 120s。 /// /// 正常盘上 188MB 的 SHA-1 不到 1s;这条只抓「校验被卡住」,不参与慢链路判定。 @@ -91,9 +150,10 @@ impl Default for EngineConfig { max_attempts_per_source: DEFAULT_MAX_ATTEMPTS_PER_SOURCE, connect_timeout: Duration::from_secs(10), read_timeout: Duration::from_secs(30), - response_timeout: Duration::from_secs(60), - chunk_stall_timeout: Duration::from_secs(120), + response_timeout: DEFAULT_RESPONSE_TIMEOUT, + chunk_stall_timeout: DEFAULT_CHUNK_STALL, chunk_job_floor: Duration::from_secs(600), + min_avg_throughput: stall::MIN_AVG_THROUGHPUT, verify_timeout: Duration::from_secs(120), permit_timeout: Duration::from_secs(120 * 60), user_agent: super::http::USER_AGENT.to_string(), @@ -119,7 +179,7 @@ impl EngineConfig { #[must_use] pub fn chunk_job_budget(&self, expected: Option) -> Duration { expected.map_or(self.chunk_job_floor, |total| { - stall::job_budget(total, self.chunk_job_floor) + stall::job_budget(total, self.chunk_job_floor, self.min_avg_throughput) }) } } @@ -158,77 +218,6 @@ pub fn gh_proxy_override(raw: &str) -> GhProxyOverride { 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`):键不存在 - /// → 出厂默认;落在区间内 → 原值;负数 / 超上限 → 出厂默认(**不夹到边界**:那是「想 9999 给 - /// 64」,界面仍会说谎)。`retry_limit` 的 `0` 在区间内,落成「只试一次」(见实现里的注释)。 - #[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), - // `0` 是**合法值**(P2-1 复审:旧配置里真的有 0,且旧 GUI 把它当档位):语义是 - // 「只试一次 = 不重试」,所以 0 与 1 都落成 1;负数 / 超上限才回落出厂默认。 - max_attempts_per_source: attempts - .and_then(|raw| u32::try_from(raw).ok()) - .filter(|raw| *raw <= MAX_ATTEMPTS_PER_SOURCE) - .map_or(default.max_attempts_per_source, |raw| raw.max(1)), - 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)), - } - } -} - /// 每个缓存根一份「宿主想要什么」([`wanted`] 用)。按数据根而不是进程级:登记处本就是「一个数据根 /// 一个引擎」;进程级那份在多数据根时会互相踩(A 根改 2、B 根冲回 8)。 fn wanted_slot() -> &'static Mutex> { diff --git a/crates/core/src/download/config/tests.rs b/crates/core/src/download/config/tests.rs index 1538681..1d2623c 100644 --- a/crates/core/src/download/config/tests.rs +++ b/crates/core/src/download/config/tests.rs @@ -1,6 +1,7 @@ //! `download::config` 的单测:**从 `config.rs` 拆出只为守 400 行硬线**(M289 加超时字段前先纯搬移,逐字节未改)。 use super::*; +use crate::download::region::RegionProfile; use crate::setting::keys::{launcher_schema, launcher_version}; /// **守门③**:schema 的默认值 / 区间 / 键名与 `EngineConfig::default()` / 调优默认值取同一组常量。 @@ -173,3 +174,225 @@ fn tuning_is_per_cache_root_and_no_change_is_reported_as_such() { ); assert_eq!(wanted(&a), DownloadTuning::default()); } + +/// M292:三个超时/阈值键的 schema 默认值与范围、`EngineConfig::default()`、`DownloadTuning::default()` +/// 三处同源,且**默认值与 M289 的硬编码逐值相等**(不设 = 当前行为)。 +/// +/// 变异证明:把 schema 默认值写成 64、把 `EngineConfig::default().response_timeout` 写回 30s、 +/// 把 `DEFAULT_THROUGHPUT_FLOOR_KIB` 从 32 改成 16 ⇒ 各自红;宿主常量 `KEY_*` 改名而 schema 没跟 +/// ⇒ `spec()` 取不到,第一条红。 +#[test] +fn the_timeout_keys_are_single_sourced_and_default_to_todays_values() { + let schema = launcher_schema(); + let cfg = EngineConfig::default(); + let tune = DownloadTuning::default(); + + // (键名, schema 默认值, 下界, 上界, EngineConfig 里的生效值, DownloadTuning 里的生效值) + let contract: [(&str, i64, i64, i64, Duration, Duration); 2] = [ + ( + KEY_RESPONSE_TIMEOUT, + i64::try_from(DEFAULT_RESPONSE_TIMEOUT.as_secs()).expect("装得下"), + i64::from(MIN_RESPONSE_TIMEOUT_SECS), + i64::from(MAX_RESPONSE_TIMEOUT_SECS), + cfg.response_timeout, + tune.response_timeout, + ), + ( + KEY_CHUNK_STALL, + i64::try_from(DEFAULT_CHUNK_STALL.as_secs()).expect("装得下"), + i64::from(MIN_CHUNK_STALL_SECS), + i64::from(MAX_CHUNK_STALL_SECS), + cfg.chunk_stall_timeout, + tune.chunk_stall_timeout, + ), + ]; + for (key, default, min, max, from_cfg, from_tune) in contract { + let spec = schema.spec(key).expect("键已登记"); + assert_eq!(spec.default, serde_json::json!(default), "{key}"); + assert_eq!( + spec.kind, + crate::setting::spec::ValueKind::Int { min, max }, + "{key}" + ); + assert_eq!(from_cfg, Duration::from_secs(default as u64), "{key}"); + assert_eq!(from_tune, from_cfg, "{key}:调优默认与引擎默认必须一致"); + } + + // 吞吐下限:schema 用 KiB/s,引擎里是字节/秒,两者只差一个 ×1024。 + let floor = schema.spec(KEY_THROUGHPUT_FLOOR).expect("键已登记"); + assert_eq!( + floor.default, + serde_json::json!(32), + "188MB/32KiB/s ≈ 100min" + ); + assert_eq!( + floor.kind, + crate::setting::spec::ValueKind::Int { + min: i64::from(MIN_THROUGHPUT_FLOOR_KIB), + max: i64::from(MAX_THROUGHPUT_FLOOR_KIB), + } + ); + assert_eq!( + cfg.min_avg_throughput, + crate::download::stall::MIN_AVG_THROUGHPUT + ); + assert_eq!(tune.min_avg_throughput, cfg.min_avg_throughput); + assert_eq!( + u64::from(DEFAULT_THROUGHPUT_FLOOR_KIB) * 1024, + cfg.min_avg_throughput, + "KiB/s 口径的出厂值与字节/秒的出厂值必须相等" + ); + + // 不设任何键 = 今天的行为(这一条是「默认值逐值相等」的总结论)。 + let untouched = + DownloadTuning::from_raw(RawTuning::default()).apply_to(EngineConfig::default()); + assert_eq!(untouched.response_timeout, cfg.response_timeout); + assert_eq!(untouched.chunk_stall_timeout, cfg.chunk_stall_timeout); + assert_eq!(untouched.min_avg_throughput, cfg.min_avg_throughput); + assert_eq!(untouched.max_concurrency, cfg.max_concurrency); + assert_eq!( + untouched.max_attempts_per_source, + cfg.max_attempts_per_source + ); +} + +/// **键变更真的进到引擎配置**(P21 判据的 core 侧一半):改设置 ⇒ `apply_to` 后引擎拿到新值 ⇒ 期限跟着变。 +/// +/// **抓得住**(三键逐一变异过,删掉 `apply_to` 里对应赋值即红):`response_timeout` ⇒ 278 行 +/// `60s/180s`;`chunk_stall_timeout` ⇒ 279 行 `120s/300s`;`min_avg_throughput` ⇒ 280 行 +/// `32768/16384`。另外还抓 `from_raw` 的 KiB→字节换算(少乘 1024 ⇒ 第三条红)与 `chunk_job_budget` +/// 忽略下限(改回常量 ⇒ 最后两条期限断言红)。 +/// +/// **抓不住**:①**宿主有没有调用 `tuning`/`apply_to`** —— 前半段归宿主用例(CLI 读 `engine.config()`、 +/// presenter 走 `host::engine::engine`);②**跑的是不是这棵树的二进制** —— M292 返工就被这一类骗过 +/// 一轮:异树/旧目标目录里的二进制正好在 279 行报 `120s/300s`,源码却从一开始就有那行。 +#[test] +fn changed_settings_reach_the_engine_config_and_the_budget() { + let tune = DownloadTuning::from_raw(RawTuning { + throughput_floor_kib: Some(16), + stall_timeout_secs: Some(300), + response_timeout_secs: Some(180), + ..RawTuning::default() + }); + assert_eq!(tune.response_timeout, Duration::from_secs(180)); + assert_eq!(tune.chunk_stall_timeout, Duration::from_secs(300)); + assert_eq!(tune.min_avg_throughput, 16 * 1024); + + let cfg = tune.apply_to(EngineConfig::default()); + assert_eq!(cfg.response_timeout, Duration::from_secs(180)); + assert_eq!(cfg.chunk_stall_timeout, Duration::from_secs(300)); + assert_eq!(cfg.min_avg_throughput, 16 * 1024); + // 没被调优管的字段保持传入值(不是被 `EngineConfig::default()` 冲掉)。 + assert_eq!(cfg.chunk_job_floor, Duration::from_secs(600)); + assert_eq!(cfg.verify_timeout, Duration::from_secs(120)); + + // 期限真的跟着变:188MB 在 16 KiB/s 上 ≈ 201 min,出厂值是 100.3 min。 + let total = 188 * 1024 * 1024; + assert_eq!( + cfg.chunk_job_budget(Some(total)), + Duration::from_secs(total / (16 * 1024)), + "调低吞吐下限 ⇒ 总期限变长(这正是慢链路用户改它的收益)" + ); + assert!( + cfg.chunk_job_budget(Some(total)) > EngineConfig::default().chunk_job_budget(Some(total)), + "与出厂值的期限必须不同,否则这条用例证明不了「改动能生效」" + ); + // 等响应头那一档是**单次请求**的界:它不参与大文件的期限,改它不会把慢链路判死。 + assert_eq!(cfg.chunk_job_budget(Some(total)), { + let mut same = cfg.clone(); + same.response_timeout = Duration::from_secs(600); + same.chunk_job_budget(Some(total)) + }); +} + +/// **非法值不得穿透**:吞吐下限 `0`(除零)、窗口 `0`、上界 `0`、负数、超上界一律回落出厂值, +/// 而且引擎侧即使真的收到 `0`(手拼 `EngineConfig`)也不会 panic / 除零。 +/// +/// 变异证明:把 `secs_in_range` 的下界判断删掉 ⇒ `0` 那几条红;把 `min_avg_throughput` 的区间 +/// 过滤删掉 ⇒ `0` 与 `999999` 两条红;把 `job_budget` 里的 `min_throughput.max(1)` 删掉 ⇒ +/// 最后一条 panic(除零)。 +#[test] +fn zero_and_out_of_range_timeouts_fall_back_instead_of_flowing_through() { + let factory = DownloadTuning::default(); + for bad in [0_i64, -1, -3600, 99_999] { + let tune = DownloadTuning::from_raw(RawTuning { + throughput_floor_kib: Some(bad), + stall_timeout_secs: Some(bad), + response_timeout_secs: Some(bad), + ..RawTuning::default() + }); + assert_eq!(tune, factory, "{bad}:三个键都该回落出厂值,不许穿透"); + } + + // 边界值本身是合法值(区间是闭区间)。 + let edge = DownloadTuning::from_raw(RawTuning { + throughput_floor_kib: Some(i64::from(MIN_THROUGHPUT_FLOOR_KIB)), + stall_timeout_secs: Some(i64::from(MIN_CHUNK_STALL_SECS)), + response_timeout_secs: Some(i64::from(MAX_RESPONSE_TIMEOUT_SECS)), + ..RawTuning::default() + }); + assert_eq!( + edge.min_avg_throughput, + u64::from(MIN_THROUGHPUT_FLOOR_KIB) * 1024 + ); + assert_eq!(edge.chunk_stall_timeout, Duration::from_secs(30)); + assert_eq!(edge.response_timeout, Duration::from_secs(600)); + + // 引擎侧纵深防御:手拼的 `EngineConfig` 带着 0 也不许 panic / 除零。 + let zero = EngineConfig { + min_avg_throughput: 0, + ..EngineConfig::default() + }; + assert_eq!( + zero.chunk_job_budget(Some(1024)), + Duration::from_secs(1024), + "0 按 1 B/s 算(有限值),不是除零也不是无界" + ); + assert_eq!(zero.chunk_job_budget(Some(0)), zero.chunk_job_floor); +} + +/// 描述里的数字(`catalog.rs` 会把它当 JSON Schema 的 `description` 发给 AI)必须是真的。 +/// +/// 变异证明:把 `MAX_CHUNK_STALL_SECS` 改成 900 而不改文案 ⇒ 第二条红;把出厂 32 改成别的 +/// ⇒ 第一条红。 +#[test] +fn the_timeout_key_docs_quote_the_real_limits() { + let schema = launcher_schema(); + let doc = |key: &str| schema.spec(key).expect("键已登记").doc; + assert!(doc(KEY_THROUGHPUT_FLOOR).contains(&format!( + "{MIN_THROUGHPUT_FLOOR_KIB}~{}", + MAX_THROUGHPUT_FLOOR_KIB + ))); + assert!(doc(KEY_THROUGHPUT_FLOOR).contains(&format!("出厂 {DEFAULT_THROUGHPUT_FLOOR_KIB}"))); + assert!( + doc(KEY_CHUNK_STALL).contains(&format!("{MIN_CHUNK_STALL_SECS}~{MAX_CHUNK_STALL_SECS}")) + ); + assert!(doc(KEY_CHUNK_STALL).contains(&format!("出厂 {}", DEFAULT_CHUNK_STALL.as_secs()))); + assert!(doc(KEY_RESPONSE_TIMEOUT).contains(&format!( + "{MIN_RESPONSE_TIMEOUT_SECS}~{MAX_RESPONSE_TIMEOUT_SECS}" + ))); + assert!( + doc(KEY_RESPONSE_TIMEOUT).contains(&format!("出厂 {}", DEFAULT_RESPONSE_TIMEOUT.as_secs())) + ); +} + +/// 日志行里能看见「引擎实际拿到了什么」(P21 判据要求的「日志里看到生效值」的 core 侧形状)。 +/// +/// 变异证明:把 `summary()` 里的 `min_avg_throughput / 1024` 写死成 32 ⇒ 第一条红;返回常数字符串 +/// ⇒ 两条都红。 +#[test] +fn the_summary_spells_out_the_effective_values() { + let factory = DownloadTuning::default().summary(); + assert!(factory.contains("吞吐下限=32KiB/s"), "{factory}"); + assert!(factory.contains("无进展窗口=120s"), "{factory}"); + assert!(factory.contains("单次请求上界=60s"), "{factory}"); + + let slow = DownloadTuning::from_raw(RawTuning { + throughput_floor_kib: Some(16), + stall_timeout_secs: Some(600), + ..RawTuning::default() + }) + .summary(); + assert!(slow.contains("吞吐下限=16KiB/s"), "{slow}"); + assert!(slow.contains("无进展窗口=600s"), "{slow}"); +} diff --git a/crates/core/src/download/config/tuning.rs b/crates/core/src/download/config/tuning.rs new file mode 100644 index 0000000..7d3a693 --- /dev/null +++ b/crates/core/src/download/config/tuning.rs @@ -0,0 +1,194 @@ +//! 设置原文 → [`EngineConfig`] 的翻译层(M292 从 `config.rs` 拆出:那边加完三个超时键就 423 行、 +//! 踩了全仓 400 行硬线)。 +//! +//! 搬移范围:`DownloadTuning` / `RawTuning` / `secs_in_range` 与 `impl DownloadTuning` 的全部方法, +//! 正文一字未改;`config.rs` 用 `pub use tuning::{DownloadTuning, RawTuning};` 把它们留在原来的路径上 +//! (`crate::download::config::DownloadTuning`),两个宿主与 `shared.rs` 都不用跟着改。 +//! +//! 「改这三个超时键要付什么代价」**不在这里**:写在 `core::download::stall` 的模块头,全仓只有那 +//! 一处。本文件只负责默认值、区间与回落。 + +use std::time::Duration; + +use super::super::region::RegionProfile; +use super::super::stall; +use super::{ + gh_proxy_override, EngineConfig, GhProxyOverride, DEFAULT_CHUNK_STALL, + DEFAULT_MAX_ATTEMPTS_PER_SOURCE, DEFAULT_MAX_CONCURRENCY, DEFAULT_RESPONSE_TIMEOUT, + MAX_ATTEMPTS_PER_SOURCE, MAX_CHUNK_STALL_SECS, MAX_CONCURRENCY, MAX_RESPONSE_TIMEOUT_SECS, + MAX_THROUGHPUT_FLOOR_KIB, MIN_CHUNK_STALL_SECS, MIN_RESPONSE_TIMEOUT_SECS, + MIN_THROUGHPUT_FLOOR_KIB, +}; + +/// 设置侧灌进来的下载调优(M193;M292 加三个超时/阈值)。 +/// +/// 这是「`launcher.json` 的 `download.*` 键」到「引擎装配参数」之间**唯一**的翻译层:键名由宿主读 +/// (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, + /// 单次请求等响应头的上界(已夹在 + /// `MIN_RESPONSE_TIMEOUT_SECS..=MAX_RESPONSE_TIMEOUT_SECS`)。 + pub response_timeout: Duration, + /// 分块作业的无进展窗口(同上,`MIN_CHUNK_STALL_SECS..=MAX_CHUNK_STALL_SECS`)。 + pub chunk_stall_timeout: Duration, + /// 分块作业的平均吞吐下限(**字节/秒**,已夹在 `MIN_THROUGHPUT_FLOOR_KIB..=MAX_THROUGHPUT_FLOOR_KIB` + /// 换算后的区间)。 + pub min_avg_throughput: u64, +} + +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, + response_timeout: DEFAULT_RESPONSE_TIMEOUT, + chunk_stall_timeout: DEFAULT_CHUNK_STALL, + min_avg_throughput: stall::MIN_AVG_THROUGHPUT, + } + } +} + +/// 宿主读出来的**裸设置值**(`None` = 键不存在 / 类型不对,两种都当「没填」)。 +/// +/// 为什么要有这个结构体(M292):默认值、区间、回落都在 [`DownloadTuning::from_raw`] **一处**, +/// 宿主只负责「按 [`KEY_THROUGHPUT_FLOOR`](super::KEY_THROUGHPUT_FLOOR) 这些常量取 `i64`」。新增键时只改这个结构体与 +/// `from_raw`——两个宿主各读一半、各回落一半,就是「界面说有、引擎没读」重新长出来的样子。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct RawTuning<'a> { + /// [`KEY_CONCURRENCY`](super::KEY_CONCURRENCY)。 + pub concurrency: Option, + /// [`KEY_RETRY_LIMIT`](super::KEY_RETRY_LIMIT)。 + pub retry_limit: Option, + /// [`KEY_GH_PROXY`](super::KEY_GH_PROXY)(三态:空串 ≠ 关)。 + pub gh_proxy: Option<&'a str>, + /// [`KEY_THROUGHPUT_FLOOR`](super::KEY_THROUGHPUT_FLOOR)(KiB/s)。 + pub throughput_floor_kib: Option, + /// [`KEY_CHUNK_STALL`](super::KEY_CHUNK_STALL)(秒)。 + pub stall_timeout_secs: Option, + /// [`KEY_RESPONSE_TIMEOUT`](super::KEY_RESPONSE_TIMEOUT)(秒)。 + pub response_timeout_secs: Option, +} + +/// `min..=max` 之间的秒数 → `Duration`(负数 / `0` / 越界一律 `None`,由调用方回落出厂值)。 +fn secs_in_range(raw: Option, min: u32, max: u32) -> Option { + let secs = u64::try_from(raw?).ok()?; + (u64::from(min)..=u64::from(max)) + .contains(&secs) + .then(|| Duration::from_secs(secs)) +} + +impl DownloadTuning { + /// 设置原文 → 调优(**未填 / 越界 / 非法值一律回落出厂默认**,绝不报错阻断下载)。 + /// + /// 口径与 schema 的 `(min, max)` 同源但独立实现(用户可能手改文件绕过 `store.set`):键不存在 + /// → 出厂默认;落在区间内 → 原值;负数 / `0` / 超上限 → 出厂默认(**不夹到边界**:那是 + /// 「想 9999 给 64」,界面仍会说谎)。`retry_limit` 的 `0` 是唯一例外,见实现里的注释。 + /// + /// 三个超时键的 `0` 也走「回落默认」而不是「夹到 1」:`0` 秒的窗口等于「立刻判失速」、 + /// `0` 字节/秒的吞吐下限是除零——两种都不是用户想要的东西,静默回落到出厂值是唯一安全的读法。 + #[must_use] + pub fn from_raw(raw: RawTuning<'_>) -> Self { + let default = Self::default(); + Self { + max_concurrency: raw + .concurrency + .and_then(|value| usize::try_from(value).ok()) + .filter(|value| (1..=MAX_CONCURRENCY).contains(value)) + .unwrap_or(default.max_concurrency), + // `0` 是**合法值**(P2-1 复审:旧配置里真的有 0,且旧 GUI 把它当档位):语义是 + // 「只试一次 = 不重试」,所以 0 与 1 都落成 1;负数 / 超上限才回落出厂默认。 + max_attempts_per_source: raw + .retry_limit + .and_then(|value| u32::try_from(value).ok()) + .filter(|value| *value <= MAX_ATTEMPTS_PER_SOURCE) + .map_or(default.max_attempts_per_source, |value| value.max(1)), + gh_proxy: raw + .gh_proxy + .map_or(GhProxyOverride::Builtin, gh_proxy_override), + response_timeout: secs_in_range( + raw.response_timeout_secs, + MIN_RESPONSE_TIMEOUT_SECS, + MAX_RESPONSE_TIMEOUT_SECS, + ) + .unwrap_or(default.response_timeout), + chunk_stall_timeout: secs_in_range( + raw.stall_timeout_secs, + MIN_CHUNK_STALL_SECS, + MAX_CHUNK_STALL_SECS, + ) + .unwrap_or(default.chunk_stall_timeout), + min_avg_throughput: raw + .throughput_floor_kib + .and_then(|value| u32::try_from(value).ok()) + .filter(|kib| (MIN_THROUGHPUT_FLOOR_KIB..=MAX_THROUGHPUT_FLOOR_KIB).contains(kib)) + .map_or(default.min_avg_throughput, |kib| u64::from(kib) * 1024), + } + } + + /// 只读三个旧键的历史入口(M193)。**新代码用 [`Self::from_raw`]**:这里三个新键恒取出厂值, + /// 宿主若把它当主入口,「设置了不生效」会静默回来。留下它是为了 core 内部(`shared` 的用例) + /// 与宿主以外的老调用点不必为了一个三参包装改签名。 + #[must_use] + pub fn from_settings( + concurrency: Option, + attempts: Option, + gh_proxy: Option<&str>, + ) -> Self { + Self::from_raw(RawTuning { + concurrency, + retry_limit: attempts, + gh_proxy, + ..RawTuning::default() + }) + } + + /// 把调优盖到 `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.response_timeout = self.response_timeout; + cfg.chunk_stall_timeout = self.chunk_stall_timeout; + cfg.min_avg_throughput = self.min_avg_throughput; + cfg + } + + /// 一行「引擎实际拿到了什么」(CLI 日志 / 诊断用)。 + /// + /// 存在的理由就是 P21 的验收判据:用户改完设置之后,得在 `logs/launcher.log` 里**看见**生效值, + /// 而不是只有「我以为我改了」。字段顺序与 `EngineConfig` 一致,单位写在名字里。 + #[must_use] + pub fn summary(&self) -> String { + let gh_proxy = match &self.gh_proxy { + GhProxyOverride::Builtin => "跟随内置默认".to_string(), + GhProxyOverride::Off => "off".to_string(), + GhProxyOverride::Prefix(prefix) => prefix.clone(), + }; + format!( + "并发={} 单源尝试={} 吞吐下限={}KiB/s 无进展窗口={}s 单次请求上界={}s gh_proxy={gh_proxy}", + self.max_concurrency, + self.max_attempts_per_source, + self.min_avg_throughput / 1024, + self.chunk_stall_timeout.as_secs(), + self.response_timeout.as_secs(), + ) + } + + /// 把 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)), + } + } +} diff --git a/crates/core/src/download/stall.rs b/crates/core/src/download/stall.rs index 6e9ab21..8897ee6 100644 --- a/crates/core/src/download/stall.rs +++ b/crates/core/src/download/stall.rs @@ -82,34 +82,62 @@ //! **整个响应体**读取,那正好就是「慢但有效被判死」的那个坑(它的默认 `None` 也是 P19 //! 记的另一半事实)。每个阶段各自的界才分得清「连不上」「不回话」「不回字节」。 //! -//! # 「慢但有效」的适用边界(r1 复审 p2-2 要求写明) +//! # 「慢但有效」的适用边界(r1 复审 p2-2 要求写明;M292 起可在设置里改) //! -//! 第 3 条那句「慢链路照样活」有一个**前提**,等价说法是:**平均吞吐 ≥ 32 KiB/s 才活得下来**。 -//! 算术:188MB 在 20 KiB/s 上要 160.4 min,而预算只有 100.3 min ⇒ 必被杀。而且一旦被杀, +//! 第 3 条那句「慢链路照样活」有一个**前提**,等价说法是:**平均吞吐 ≥ 32 KiB/s 才活得下来** +//! —— 32 KiB/s 是**出厂默认值**,不是不可变的事实(M292 起三个数都是设置键,见下一节)。 +//! 算术:188MB 在 20 KiB/s 上要 160.4 min,而出厂预算只有 100.3 min ⇒ 必被杀。而且一旦被杀, //! `DownloadError::job_budget_exhausted()` 让引擎**不再重试、也不再换源**(同一份字节、 //! 同一条链路,候选源不可能更快)——所以在 <32 KiB/s 的链路上,这个文件大小是「下不完」, -//! 不是「慢一点」。要让用户自己调这几个数,得接 `download.*` 的 schema(`setting/keys.rs` -//! 不在 M289 的 scope,是另一个 mission);在那之前出厂值就是唯一值。 +//! 不是「慢一点」。 //! //! 这条边界的命中面不远:仓内就有 ≈67 KB/s 的真机观测(`font/tests/support.rs` 的文件头)。 -//! 真实公网慢链路的取值**没有真机验证**(M289 报告列为未验项)。 +//! 真实公网慢链路的取值**没有真机验证**(M289 报告列为未验项,M292 也没有补)。 //! -//! # 数字从哪来(全仓只写这一处) +//! # 改这几个数要付的代价(**全仓只写这一处**:键的 `doc` 与 `download::config` 都只指回这里) //! -//! * `response_timeout` = 60s:连接已有 10s 的 `connect_timeout` 兜底,这 60s 是**连接 -//! 建立之后**等响应头的余额——CDN 排队、回源冷启动都在几十秒量级;响应头本身只有 -//! 几百字节,能回的话 60s 内必回。 -//! * `read_timeout` = 30s(既有值,不动):空闲判定。持续有字节就永远不触发。 -//! * `chunk_stall_timeout` = 120s:4 条连接**整体**一个新字节都没有两分钟,只可能是 -//! 分块任务卡住(写盘卡在杀软/网络盘上时,读超时不会响,只有这个会)。 -//! * [`MIN_AVG_THROUGHPUT`] = 32 KiB/s:分块作业的总期限 = 总字节 / 32 KiB/s -//! (188MB ≈ 100 分钟)。适用边界与后果见上一节——**这是「≥32 KiB/s 才活得下来」 -//! 的等价说法**,不是「慢一点的链路也没事」。 -//! * `permit_timeout` = 120min:等许可的上界。它必须 **≥ 单个作业的总期限** +//! 三个键(登记在 `setting::keys::download`)在引擎侧落成 +//! `EngineConfig::{min_avg_throughput, chunk_stall_timeout, response_timeout}`,通路是 +//! `DownloadTuning::apply_to`;**不设 = 与 M289 逐值相同的出厂值**,所以「读这一节」只在真的 +//! 填了数字之后才有意义。 +//! +//! * **把吞吐下限调低**(`download.throughput_floor_kib`):慢链路能下完 —— 20 KiB/s 的用户把 +//! 它设成 16,188MB 的预算从 100.3 min 变成 201 min。代价是「真挂住」的作业也被允许占着许可 +//! **更久**:总期限是 `总字节 / 下限`,下限减半 ⇒ 期限翻倍。它换到的是「慢」而不是「无界」: +//! 字节完全不动仍然由 `stall_timeout_secs` 那一档兜(那是 `NoProgress`,与总期限是两个原因)。 +//! * **把无进展窗口 / 单次请求上界调大**(`stall_timeout_secs` / `response_timeout_secs`): +//! 检测变慢。这两档都是**单次尝试**的界,所以最坏等待是 +//! `Σ(候选数 × 每源尝试次数 × 界)` —— 600s 的窗口(本键上界)× 3 次重试 × 6 个候选就是 3 小时。 +//! 上界常量(`download::config` 的 `MAX_*`)就是这道兜底:比它更大的值在**写入口**被 schema 拒、 +//! 引擎侧再回落一次出厂值,所以「窗口 24 小时」写不进去,不存在「调大等于无界」的那条路。 +//! * **与未暴露的 `permit_timeout`(120 min)的耦合**:那个数是按「出厂下限下的**单次尝试**预算」 +//! 反推的。下限调到 8 KiB/s 时 188MB 的单次预算变成 400 min > 120 min,于是 +//! `download.concurrency = 1` 时**排队**的那条可能等不到许可(正在下的那条不受影响,它已经 +//! 持有许可)。这是已知的耦合,M292 没解决(暴露 `permit_timeout` 或让它随预算派生是另一个 +//! 决定);命中前提是「把下限调得很低 **且** 并发很小 **且** 单文件很大」。 +//! +//! # 数字从哪来(默认值全仓只写这一处;能不能改也写在这里) +//! +//! * `response_timeout` = 60s(**可改**:`download.response_timeout_secs`,10~600s):连接已有 +//! 10s 的 `connect_timeout` 兜底,这 60s 是**连接建立之后**等响应头的余额——CDN 排队、 +//! 回源冷启动都在几十秒量级;响应头本身只有几百字节,能回的话 60s 内必回。 +//! * `read_timeout` = 30s(既有值,不动;**没有**设置键):空闲判定,持续有字节就永远不触发。 +//! * `chunk_stall_timeout` = 120s(**可改**:`download.stall_timeout_secs`,30~600s):4 条连接 +//! **整体**一个新字节都没有两分钟,只可能是分块任务卡住(写盘卡在杀软/网络盘上时,读超时 +//! 不会响,只有这个会)。 +//! * [`MIN_AVG_THROUGHPUT`] = 32 KiB/s(**可改**:`download.throughput_floor_kib`,4~4096 KiB/s): +//! 分块作业的总期限 = 总字节 / 这个数(188MB ≈ 100 分钟)。适用边界与代价见上一节。 +//! * `chunk_job_floor` = 600s(**没有**设置键):小作业的下限。吞吐算式对几 MB 的文件没有意义 +//! (`8MB / 32KiB/s` 才 4 分钟,太紧),所以小作业一律拿到这 10 分钟。 +//! * `permit_timeout` = 120min(**没有**设置键):等许可的上界。它必须 **≥ 单次尝试的总期限** //! (最坏 ≈ 100 min 预算 + 120s 校验),否则 `download.concurrency = 1` 时正常排队 //! 会被误杀;它挡的是「许可被永久占住」(M289 之前每一处无界 `await` 都能造出来)。 -//! * `verify_timeout` = 120s:188MB 的 SHA-1 在正常盘上不到 1s,120s 只抓「整文件重算 -//! 摘要被卡住」(网络盘掉线、杀软锁文件),不参与慢链路判定。 +//! **口径是「一次尝试」,不是「一个作业」**(P21 第 3 条):一个 `download()` 调用的最坏时长是 +//! `Σ(候选数 × 每源尝试次数 × 每次尝试上界)`,理论上十几小时——但预算耗尽会让引擎不再重试、 +//! 也不再换源(`job_budget_exhausted` 短路整个作业),所以那条算术走不到底。 +//! 与调低的下限之间的耦合见上一节最后一条。 +//! * `verify_timeout` = 120s(**没有**设置键):188MB 的 SHA-1 在正常盘上不到 1s,120s 只抓 +//! 「整文件重算摘要被卡住」(网络盘掉线、杀软锁文件),不参与慢链路判定。 //! //! # 口径(读这两个数之前先看这里) //! @@ -120,7 +148,11 @@ use std::fmt; use std::time::Duration; -/// 分块作业的**最低平均吞吐**(字节/秒):整作业总期限按它反推(见 [`job_budget`])。 +/// 分块作业的**出厂**最低平均吞吐(字节/秒):整作业总期限按它反推(见 [`job_budget`])。 +/// +/// 这是 `download.throughput_floor_kib` 的默认值来源(`download::config` 的 +/// `DEFAULT_THROUGHPUT_FLOOR_KIB` 由它算出)。用户改过之后 [`job_budget`] 收的是 +/// `EngineConfig::min_avg_throughput`,**不是**这个常量——别把它当成「引擎一定用的值」。 pub const MIN_AVG_THROUGHPUT: u64 = 32 * 1024; /// 卡在哪一段(诊断字段;`as_str` 是稳定 id,`label` 是给人看的中文)。 @@ -291,88 +323,21 @@ pub struct Deadlines { pub read_idle: Duration, } -/// 分块作业的总期限:`max(floor, 总字节 / MIN_AVG_THROUGHPUT)`。 +/// 分块作业的总期限:`max(floor, 总字节 / min_throughput)`。 +/// +/// `min_throughput` 是**字节/秒**,来自 `EngineConfig::min_avg_throughput`(出厂 +/// [`MIN_AVG_THROUGHPUT`],可被 `download.throughput_floor_kib` 改)。除零在**这一层**就挡住: +/// `0` 按 1 B/s 算(期限变成天文数字但仍是有限的 `Duration`,不 panic)——设置层的区间过滤 +/// 本来就不让 0 进来,这里是纵深防御(手拼 `EngineConfig` 的调用点、老 schema 允许的旧值)。 /// /// 为什么不是固定秒数:固定值对小作业太松、对大作业太紧;按平均吞吐反推, /// 「能下得完的慢链路」永远不会撞上它,撞上的只有「慢到已经没意义」的作业。 #[must_use] -pub fn job_budget(total: u64, floor: Duration) -> Duration { - floor.max(Duration::from_secs(total / MIN_AVG_THROUGHPUT)) +pub fn job_budget(total: u64, floor: Duration, min_throughput: u64) -> Duration { + // `total / 1 = total` 秒在 `Duration` 里装得下(u64 秒 ≈ 5840 亿年),不会溢出。 + floor.max(Duration::from_secs(total / min_throughput.max(1))) } #[cfg(test)] -mod tests { - use super::{job_budget, Deadlines, StallReason, StallReport, StallStage, MIN_AVG_THROUGHPUT}; - use std::time::Duration; - - /// 总期限按平均吞吐反推:188MB 的作业拿到 ≈100 分钟,小作业只拿到下限。 - /// - /// 变异证明:把 `job_budget` 写成常量(比如 `Duration::from_secs(30)`)⇒ 第一条红; - /// 把 `floor.max(..)` 写成 `min` ⇒ 第二条红(188MB 只剩 100 分钟里的 `floor`)。 - #[test] - fn the_job_budget_scales_with_size_and_never_drops_below_the_floor() { - let floor = Duration::from_secs(600); - let big = job_budget(188 * 1024 * 1024, floor); - let by_throughput = Duration::from_secs(188 * 1024 * 1024 / MIN_AVG_THROUGHPUT); - assert_eq!(big, by_throughput, "大作业的期限由平均吞吐决定"); - assert!(big > Duration::from_secs(90 * 60), "188MB 至少给到 90 分钟"); - assert_eq!( - job_budget(8 * 1024, floor), - floor, - "小作业只拿到下限(吞吐算式在这里没有意义)" - ); - } - - /// 诊断串必须同时有:阶段(中英文都给)、已收字节、预期字节、无进展时长、原因。 - /// - /// 变异证明:删掉 `stage` 那一段 ⇒ 前两条 red;把 `received` / `expected` 写成常量 - /// 或删掉 ⇒ 对应断言 red;把 `reason` 删掉 ⇒ 最后一条 red。 - #[test] - fn the_report_spells_out_stage_three_numbers_and_reason() { - let report = StallReport::no_progress( - StallStage::AwaitBytes, - 4096, - Some(188 * 1024 * 1024), - Duration::from_millis(30_000), - ); - let text = report.to_string(); - assert!(text.contains("等字节"), "{text}"); - assert!(text.contains("await_bytes"), "{text}"); - assert!(text.contains("已收 4096 字节"), "{text}"); - assert!(text.contains("预期 197132288 字节"), "{text}"); - assert!(text.contains("已 30000ms 无进展"), "{text}"); - assert!(text.contains("原因=没有新字节"), "{text}"); - assert!(report.reason == StallReason::NoProgress); - - // 总长未知也要能说清楚(不是「预期 0」)。 - let unknown = - StallReport::deadline(StallStage::AwaitHeaders, 0, None, Duration::from_secs(60)); - assert!(unknown.to_string().contains("预期未知"), "{unknown}"); - assert_eq!(unknown.reason, StallReason::Deadline); - } - - /// 阶段 id 是日志里的 grep 锚点:改名字等于改诊断口径,得先改这里。 - #[test] - fn stage_and_reason_ids_are_stable() { - let stages = [ - (StallStage::Connect, "connect", "建立连接"), - (StallStage::AwaitHeaders, "await_headers", "等响应头"), - (StallStage::AwaitBytes, "await_bytes", "等字节"), - (StallStage::AcquirePermit, "acquire_permit", "等许可"), - (StallStage::AwaitChunkJoin, "await_chunk_join", "等分块回收"), - (StallStage::Verify, "verify", "校验"), - ]; - for (stage, id, label) in stages { - assert_eq!(stage.as_str(), id); - assert_eq!(stage.label(), label); - } - assert_eq!(StallReason::Deadline.as_str(), "deadline"); - assert_eq!(StallReason::NoProgress.as_str(), "no_progress"); - - let deadlines = Deadlines { - response: Duration::from_secs(60), - read_idle: Duration::from_secs(30), - }; - assert_eq!(deadlines.response, Duration::from_secs(60)); - } -} +#[path = "stall/tests.rs"] +mod tests; diff --git a/crates/core/src/download/stall/tests.rs b/crates/core/src/download/stall/tests.rs new file mode 100644 index 0000000..0b720ba --- /dev/null +++ b/crates/core/src/download/stall/tests.rs @@ -0,0 +1,103 @@ +//! `stall` 的单测(M292 从 `stall.rs` 拆出:那边加完「改这几个数要付的代价」那节就 440 行、 +//! 踩了全仓 400 行硬线)。**逐字搬来**,唯一变换是整体去 4 格缩进(`mod tests` 体 → 文件顶层), +//! 由 `stall.rs` 末尾的 `#[path = "stall/tests.rs"] mod tests;` 收编。 + +use super::{job_budget, Deadlines, StallReason, StallReport, StallStage, MIN_AVG_THROUGHPUT}; +use std::time::Duration; + +/// 总期限按平均吞吐反推:188MB 的作业拿到 ≈100 分钟,小作业只拿到下限。 +/// +/// 变异证明:把 `job_budget` 写成常量(比如 `Duration::from_secs(30)`)⇒ 第一条红; +/// 把 `floor.max(..)` 写成 `min` ⇒ 第二条红(188MB 只剩 100 分钟里的 `floor`); +/// 把 `min_throughput` 参数忽略、写回常量 ⇒ 第三条红(调低下限时期限不变)。 +#[test] +fn the_job_budget_scales_with_size_and_never_drops_below_the_floor() { + let floor = Duration::from_secs(600); + let big = job_budget(188 * 1024 * 1024, floor, MIN_AVG_THROUGHPUT); + let by_throughput = Duration::from_secs(188 * 1024 * 1024 / MIN_AVG_THROUGHPUT); + assert_eq!(big, by_throughput, "大作业的期限由平均吞吐决定"); + assert!(big > Duration::from_secs(90 * 60), "188MB 至少给到 90 分钟"); + assert_eq!( + job_budget(8 * 1024, floor, MIN_AVG_THROUGHPUT), + floor, + "小作业只拿到下限(吞吐算式在这里没有意义)" + ); + // M292:设置里的吞吐下限(`download.throughput_floor_kib`)真的进到这个算式。 + let half = job_budget(188 * 1024 * 1024, floor, MIN_AVG_THROUGHPUT / 2); + assert_eq!(half, by_throughput * 2, "下限减半 ⇒ 期限翻倍"); +} + +/// 吞吐下限被设成 `0` **不会除零、不会 panic**(M292 任务里点名的那一条)。 +/// +/// 设置层的区间过滤(`download.throughput_floor_kib` 是 4~4096)本来就不让 0 进来,但 +/// `EngineConfig` 可以被宿主手拼,所以这一层必须自己站住:`0` 按 1 B/s 算完再落到 +/// `floor`/有限值上,而不是 `u64` 除零 panic 或者拿到 `Duration::MAX`。 +/// +/// 变异证明:把 `min_throughput.max(1)` 删掉 ⇒ 本用例第一个断言 panic(除零);把 +/// `floor.max(..)` 的顺序颠倒 ⇒ 第二条红(`0` 字节的作业拿到 0 秒期限)。 +#[test] +fn a_zero_throughput_floor_neither_panics_nor_divides_by_zero() { + let floor = Duration::from_secs(600); + assert_eq!( + job_budget(1024, floor, 0), + Duration::from_secs(1024), + "0 按 1 B/s 算:1024 字节 ⇒ 1024 秒,不 panic" + ); + assert_eq!( + job_budget(0, floor, 0), + floor, + "0 字节的作业不许拿到 0 秒期限" + ); +} + +/// 诊断串必须同时有:阶段(中英文都给)、已收字节、预期字节、无进展时长、原因。 +/// +/// 变异证明:删掉 `stage` 那一段 ⇒ 前两条 red;把 `received` / `expected` 写成常量 +/// 或删掉 ⇒ 对应断言 red;把 `reason` 删掉 ⇒ 最后一条 red。 +#[test] +fn the_report_spells_out_stage_three_numbers_and_reason() { + let report = StallReport::no_progress( + StallStage::AwaitBytes, + 4096, + Some(188 * 1024 * 1024), + Duration::from_millis(30_000), + ); + let text = report.to_string(); + assert!(text.contains("等字节"), "{text}"); + assert!(text.contains("await_bytes"), "{text}"); + assert!(text.contains("已收 4096 字节"), "{text}"); + assert!(text.contains("预期 197132288 字节"), "{text}"); + assert!(text.contains("已 30000ms 无进展"), "{text}"); + assert!(text.contains("原因=没有新字节"), "{text}"); + assert!(report.reason == StallReason::NoProgress); + + // 总长未知也要能说清楚(不是「预期 0」)。 + let unknown = StallReport::deadline(StallStage::AwaitHeaders, 0, None, Duration::from_secs(60)); + assert!(unknown.to_string().contains("预期未知"), "{unknown}"); + assert_eq!(unknown.reason, StallReason::Deadline); +} + +/// 阶段 id 是日志里的 grep 锚点:改名字等于改诊断口径,得先改这里。 +#[test] +fn stage_and_reason_ids_are_stable() { + let stages = [ + (StallStage::Connect, "connect", "建立连接"), + (StallStage::AwaitHeaders, "await_headers", "等响应头"), + (StallStage::AwaitBytes, "await_bytes", "等字节"), + (StallStage::AcquirePermit, "acquire_permit", "等许可"), + (StallStage::AwaitChunkJoin, "await_chunk_join", "等分块回收"), + (StallStage::Verify, "verify", "校验"), + ]; + for (stage, id, label) in stages { + assert_eq!(stage.as_str(), id); + assert_eq!(stage.label(), label); + } + assert_eq!(StallReason::Deadline.as_str(), "deadline"); + assert_eq!(StallReason::NoProgress.as_str(), "no_progress"); + + let deadlines = Deadlines { + response: Duration::from_secs(60), + read_idle: Duration::from_secs(30), + }; + assert_eq!(deadlines.response, Duration::from_secs(60)); +} diff --git a/crates/core/src/setting/keys.rs b/crates/core/src/setting/keys.rs index 7cba6a7..65b6c4c 100644 --- a/crates/core/src/setting/keys.rs +++ b/crates/core/src/setting/keys.rs @@ -37,8 +37,9 @@ pub const JAVA_INSTALL_ROOT_UNSET: &str = ""; /// 必须同一份集合(setting 层不依赖 game 层),一致性由 `tests/setting/java_install_keys.rs` 守。 pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu", "termux"]; -/// 拆到各自文件的键域:中文字体 CDN(M168)/ 自更新(M161)/ 代理与 gh-proxy(M197)。 -/// 主表各留一行登记。 +/// 拆到各自文件的键域:中文字体 CDN(M168)/ 自更新(M161)/ 代理与 gh-proxy(M197)/ +/// 下载调优四键(M292)。主表各留一行登记。 +pub mod download; pub mod font; pub mod proxy; pub mod update; @@ -59,6 +60,9 @@ pub mod update; /// `1.7.0` 登记 M168 的中文字体下载地址覆盖键(`ui.font_cdn`,见 [`font`]):纯增量、无迁移。 /// `1.8.0` 登记 M197 的 gh-proxy 前缀覆盖键(`download.gh_proxy`):同样纯增量——出厂值是空串, /// 语义正是「老文件没有这个键」时的行为(跟随内置镜像表的默认前缀)。 +/// `1.9.0` 登记 M292 的三个下载超时/阈值键(`download.throughput_floor_kib` / +/// `stall_timeout_secs` / `response_timeout_secs`,见 [`download`]):纯增量,且**默认值与 M289 的 +/// 硬编码逐值相等**——老文件缺这三键时的行为就是 M292 之前的行为。 /// /// **旧读新(方向相反,措辞必须精确)**:1.4.0 的客户端读 1.5.0 的文件时, /// [`super::schema::SchemaCompat::check_url`] 走 `found.minor > expected.minor` → @@ -68,7 +72,7 @@ pub mod update; /// 原因:回退到旧版本会出现「设置改不动」,而原因只在 `$schema` 那一行里,不看版本号无从排查。 #[must_use] pub fn launcher_version() -> SchemaVersion { - SchemaVersion::new(1, 8, 0) + SchemaVersion::new(1, 9, 0) } /// `config/launcher.json` 的 schema。 @@ -106,34 +110,9 @@ pub fn launcher_schema() -> SettingSchema { AiPolicy::sensitive(), "实例默认隔离模式:isolated(运行目录派生 versions/<版本 id>,D20 默认)/ shared(所有实例共用 .minecraft 根)", )) - .with(SettingSpec::int( - "download.concurrency", - crate::download::config::DEFAULT_MAX_CONCURRENCY as i64, - (1, crate::download::config::MAX_CONCURRENCY as i64), - AiPolicy::silent(), - "同时下载的文件数(1~64,出厂 8)。这是引擎真实持有的并发许可,不是展示值;越界或手改坏值按出厂值生效", - )) - .with(SettingSpec::int( - "download.retry_limit", - crate::download::config::DEFAULT_MAX_ATTEMPTS_PER_SOURCE as i64, - (0, crate::download::config::MAX_ATTEMPTS_PER_SOURCE as i64), - AiPolicy::silent(), - "单个下载源的最多尝试次数(含首次;出厂 3)。0 与 1 都等于「只试一次、不重试」——0 是旧版 GUI 提供过的档位,保留合法但不代表「不下载」。换源不受此限", - )) - .with(SettingSpec::enumeration( - "download.region", - "auto", - REGIONS, - AiPolicy::audited(), - "下载地区判定:auto(时区+语言+探测)/ cn / global", - )) - .with(SettingSpec::enumeration( - "download.mirror_preference", - "auto", - MIRROR_PREFERENCES, - AiPolicy::audited(), - "镜像偏好:auto(健康度评分)/ official(官方源优先)/ mirror(镜像优先)", - )) + // M292:下载调优四键(并发 / 单源尝试次数 / 地区 / 镜像偏好)搬到 [`download`]——主表这一行 + // 代替原先四条 `.with(..)`,登记顺序与搬到那边之前逐项相同。 + .with_all(download::specs()) // M197:代理与 gh-proxy 三键(`download.proxy_mode` / `proxy_url` / `gh_proxy`)搬到 // [`proxy`]——主表这一段的注释也一起搬过去,见那个模块的表格。 .with_all(proxy::specs()) diff --git a/crates/core/src/setting/keys/download.rs b/crates/core/src/setting/keys/download.rs new file mode 100644 index 0000000..4d19b19 --- /dev/null +++ b/crates/core/src/setting/keys/download.rs @@ -0,0 +1,118 @@ +//! `download.*` 的七个键:四个基础键(M292 从 `keys.rs` 纯搬移拆出:那边 396 行、贴着 400 行硬线) +//! + M292 新增的三个超时/阈值键。 +//! +//! 搬移范围与顺序都未改:并发 / 单源尝试次数 / 地区判定 / 镜像偏好,四项逐字搬来,主表只留 +//! `.with_all(download::specs())` 一行。语义与「谁消费」仍写在各自的 `doc` 里(那是给界面与 +//! AI 的 JSON Schema `description`,不是本文件的注释),所以这里不再抄第二份。 +//! +//! 与 [`super::proxy`] 的分工:代理 / gh-proxy 三个键在那边(它们问的是「怎么把包拿到手」, +//! 而本文件这些问的是「怎么排队、怎么重试、从哪个源拿、允许慢到什么程度」)。 +//! +//! **三个超时键的默认值与 M289 的硬编码逐值相等**(32 KiB/s / 120s / 60s),所以「不设」等于 +//! 「M292 之前的行为」。**改它们的代价**只写在一处:`core::download::stall` 的模块头——本文件与 +//! `download/config.rs` 都只指回那里,不各写一份。 +//! +//! 三者的上界(`download::config` 的 `MAX_*` 常量)就是「把挂住变成无界」那道兜底:比它更大的值 +//! 在写入口被 schema 拒、引擎侧再回落一次出厂值。 + +use crate::setting::spec::{AiPolicy, SettingSpec}; + +use super::{MIRROR_PREFERENCES, REGIONS}; + +/// 七个键的完整登记项(主表一行 `.with_all(download::specs())`)。 +#[must_use] +pub fn specs() -> Vec { + vec![ + SettingSpec::int( + "download.concurrency", + crate::download::config::DEFAULT_MAX_CONCURRENCY as i64, + (1, crate::download::config::MAX_CONCURRENCY as i64), + AiPolicy::silent(), + "同时下载的文件数(1~64,出厂 8)。这是引擎真实持有的并发许可,不是展示值;越界或手改坏值按出厂值生效", + ), + SettingSpec::int( + "download.retry_limit", + crate::download::config::DEFAULT_MAX_ATTEMPTS_PER_SOURCE as i64, + (0, crate::download::config::MAX_ATTEMPTS_PER_SOURCE as i64), + AiPolicy::silent(), + "单个下载源的最多尝试次数(含首次;出厂 3)。0 与 1 都等于「只试一次、不重试」——0 是旧版 GUI 提供过的档位,保留合法但不代表「不下载」。换源不受此限", + ), + SettingSpec::enumeration( + "download.region", + "auto", + REGIONS, + AiPolicy::audited(), + "下载地区判定:auto(时区+语言+探测)/ cn / global", + ), + SettingSpec::enumeration( + "download.mirror_preference", + "auto", + MIRROR_PREFERENCES, + AiPolicy::audited(), + "镜像偏好:auto(健康度评分)/ official(官方源优先)/ mirror(镜像优先)", + ), + // M292:三个超时/阈值键。默认值与 M289 的硬编码逐值相同(不设 = 当前行为), + // 「改它们要付什么代价」只写在 `core::download::stall` 的模块头,这里只给范围与默认值。 + SettingSpec::int( + crate::download::config::KEY_THROUGHPUT_FLOOR, + i64::from(crate::download::config::DEFAULT_THROUGHPUT_FLOOR_KIB), + ( + i64::from(crate::download::config::MIN_THROUGHPUT_FLOOR_KIB), + i64::from(crate::download::config::MAX_THROUGHPUT_FLOOR_KIB), + ), + AiPolicy::silent(), + "分块下载的最低下限平均吞吐(KiB/s,4~4096,出厂 32):下载总期限 = 总字节 ÷ 这个数。调低能让慢链路下完,代价是真挂住的作业占用更久(见 core::download::stall 模块头)", + ), + SettingSpec::int( + crate::download::config::KEY_CHUNK_STALL, + crate::download::config::DEFAULT_CHUNK_STALL.as_secs() as i64, + ( + i64::from(crate::download::config::MIN_CHUNK_STALL_SECS), + i64::from(crate::download::config::MAX_CHUNK_STALL_SECS), + ), + AiPolicy::silent(), + "分块下载「这么久没有任何新字节」就判失速(秒,30~600,出厂 120)。调大 = 卡住时报错更晚;与重试次数相乘后才是最坏等待", + ), + SettingSpec::int( + crate::download::config::KEY_RESPONSE_TIMEOUT, + crate::download::config::DEFAULT_RESPONSE_TIMEOUT.as_secs() as i64, + ( + i64::from(crate::download::config::MIN_RESPONSE_TIMEOUT_SECS), + i64::from(crate::download::config::MAX_RESPONSE_TIMEOUT_SECS), + ), + AiPolicy::silent(), + "单次请求等响应头的上界(秒,10~600,出厂 60):只算到响应头到手,不含正文,所以对慢链路下大文件是安全的一档", + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 七项的键名与顺序。搬移若换了顺序,`launcher_schema()` 的 `specs()` 投影顺序也跟着变 + /// (那是可见行为:`ull config list` / AI 的 JSON Schema 都按它排),而 `keys.rs` 里那行 + /// `.with_all(download::specs())` 看不出这件事。 + /// + /// 默认值 / 区间 / 与 `EngineConfig` 的同源关系不在这里守——那是 + /// `download/config/tests.rs` 的 `the_timeout_keys_are_single_sourced_and_default_to_todays_values` + /// (跨 crate 模块的那份口径只写一处,别在这里抄第二份)。 + /// + /// 变异证明:把三个新键挪到 `region` 前面 ⇒ 红;把 `stall_timeout_secs` 改名 ⇒ 红。 + #[test] + fn the_download_keys_are_registered_in_this_order() { + let keys: Vec<&str> = specs().iter().map(|spec| spec.key).collect(); + assert_eq!( + keys, + [ + "download.concurrency", + "download.retry_limit", + "download.region", + "download.mirror_preference", + "download.throughput_floor_kib", + "download.stall_timeout_secs", + "download.response_timeout_secs", + ] + ); + } +} diff --git a/crates/core/tests/setting/appearance_keys.rs b/crates/core/tests/setting/appearance_keys.rs index fc59988..c9ca2d3 100644 --- a/crates/core/tests/setting/appearance_keys.rs +++ b/crates/core/tests/setting/appearance_keys.rs @@ -131,8 +131,8 @@ fn appearance_keys_are_all_ai_writable_and_registered() { fn schema_registration_shape_is_right() { assert_eq!( launcher_version().minor, - 8, - "1.8.0 登记 M197 的 download.gh_proxy(1.7.0 是 M168 的 ui.font_cdn)" + 9, + "1.9.0 登记 M292 的三个下载超时键(1.8.0 是 M197 的 download.gh_proxy,1.7.0 是 M168 的 ui.font_cdn)" ); let schema = launcher_schema(); assert_eq!( diff --git a/crates/core/tests/setting/isolation_keys.rs b/crates/core/tests/setting/isolation_keys.rs index d628dab..33277bf 100644 --- a/crates/core/tests/setting/isolation_keys.rs +++ b/crates/core/tests/setting/isolation_keys.rs @@ -128,10 +128,11 @@ fn overrides_key_is_an_enum_whitelist() { #[test] fn schema_versions_register_the_new_keys_without_migration() { - // 1.8.0 登记 M197 的 `download.gh_proxy`(1.7.0 = M168 的 `ui.font_cdn`,1.6.0 登记 M161 的 + // 1.9.0 登记 M292 的三个下载超时键(吞吐下限 / 无进展窗口 / 单次请求上界); + // 1.8.0 = M197 的 `download.gh_proxy`(1.7.0 = M168 的 `ui.font_cdn`,1.6.0 登记 M161 的 // 自更新两键,1.5.0 = M116 窗口圆角,1.4.0 = M91 外观下层三键,1.3.0 = D17「外观(高级)」, // 1.2.0 = D23 的 JDK 落点/优先级键)。 - assert_eq!(launcher_version(), SchemaVersion::new(1, 8, 0)); + assert_eq!(launcher_version(), SchemaVersion::new(1, 9, 0)); assert_eq!(instance_version(), SchemaVersion::new(1, 1, 0)); assert_eq!(launcher_schema().id(), SchemaId::LAUNCHER); // major 仍是 1:迁移链(按 major 走)不需要新步骤,老文件缺键即默认值。 diff --git a/crates/core/tests/setting/java_install_keys.rs b/crates/core/tests/setting/java_install_keys.rs index 2cc7d4a..d9dee06 100644 --- a/crates/core/tests/setting/java_install_keys.rs +++ b/crates/core/tests/setting/java_install_keys.rs @@ -66,10 +66,11 @@ fn install_root_accepts_a_path_and_keeps_empty_as_unset() { #[test] fn schema_version_bumps_minor_only_so_no_migration_step_is_needed() { - // 1.8.0 = M197 的 gh-proxy 覆盖键(`download.gh_proxy`;此前 1.7.0 是 M168 的 `ui.font_cdn`, + // 1.9.0 = M292 的三个下载超时键(吞吐下限 / 无进展窗口 / 单次请求上界); + // 1.8.0 = M197 的 gh-proxy 覆盖键(此前 1.7.0 是 M168 的 `ui.font_cdn`, // 1.6.0 是 M161 的自更新两键,1.5.0 是 M116 窗口圆角)。每加一批键推一次 minor 是**纪律**, // 不是巧合:老版本读新文件会判 `ReadOnlyPreserveSchema`,所以版本号本身就是「新键存在」的对外声明。 - assert_eq!(launcher_version(), SchemaVersion::new(1, 8, 0)); + assert_eq!(launcher_version(), SchemaVersion::new(1, 9, 0)); assert_eq!( launcher_version().major, 1, diff --git a/crates/presenter/src/host/settings/download.rs b/crates/presenter/src/host/settings/download.rs index 41977ee..aa693a9 100644 --- a/crates/presenter/src/host/settings/download.rs +++ b/crates/presenter/src/host/settings/download.rs @@ -13,10 +13,16 @@ //! ```text //! config/launcher.json ←── store.set(`settings::set`,本模块只读) //! download.concurrency ─┐ -//! download.retry_limit ─┼─→ DownloadTuning(core 决定默认值与夹取)─→ shared::set_tuning -//! download.gh_proxy ───┘ (引擎重建时生效) +//! download.retry_limit ─┤ +//! download.gh_proxy ────┼─→ DownloadTuning(core 决定默认值与夹取)─→ shared::set_tuning +//! download.throughput_floor_kib ─┤ (引擎重建时生效) +//! download.stall_timeout_secs ───┤ +//! download.response_timeout_secs ┘ //! ``` //! +//! M292:后三个键(吞吐下限 / 无进展窗口 / 单次请求上界)只经 [`tuning_of`] 进引擎,**没有**对应的 +//! 界面行——它们的说明与代价写在 `core::download::stall` 的模块头,界面行是另一个 mission 的事。 +//! //! **调优的同步点是显式的**:`host/engine.rs` 每次取引擎前、`dispatch.rs` 的 `SetSetting` 分支里, //! 都调 [`apply_tuning`](幂等:值没变就什么都不做)。它**不是**藏在某个读函数里的副作用—— //! 「读起来是纯读、实际改全局状态」正是本仓吃过亏的那类形状。 @@ -241,20 +247,29 @@ fn gh_proxy_state(state: &GhProxyOverride) -> String { } /// 从 `launcher.json` 读出下载调优(默认值与夹取口径在 core,本函数只做「取原始值」)。 +/// +/// M292:六个键一起读(并发 / 单源尝试 / gh-proxy / 吞吐下限 / 无进展窗口 / 单次请求上界), +/// 缺省与越界一律由 core 的 [`DownloadTuning::from_raw`] 回落——本函数不判断任何区间。 #[must_use] pub(crate) fn tuning_of(env: &HostEnv) -> DownloadTuning { - use unlucky_core::download::config::{KEY_CONCURRENCY, KEY_GH_PROXY, KEY_RETRY_LIMIT}; + use unlucky_core::download::config::{ + RawTuning, KEY_CHUNK_STALL, KEY_CONCURRENCY, KEY_GH_PROXY, KEY_RESPONSE_TIMEOUT, + KEY_RETRY_LIMIT, KEY_THROUGHPUT_FLOOR, + }; 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()), - ) + DownloadTuning::from_raw(RawTuning { + concurrency: doc.effective_i64(KEY_CONCURRENCY), + retry_limit: doc.effective_i64(KEY_RETRY_LIMIT), + gh_proxy: Some(gh.as_str()), + throughput_floor_kib: doc.effective_i64(KEY_THROUGHPUT_FLOOR), + stall_timeout_secs: doc.effective_i64(KEY_CHUNK_STALL), + response_timeout_secs: doc.effective_i64(KEY_RESPONSE_TIMEOUT), + }) } /// 把当前设置灌进 core 的下载调优(按数据根),返回**是否真的变了**(变了就失效该根的引擎)。 diff --git a/crates/presenter/src/host/settings/download_tests.rs b/crates/presenter/src/host/settings/download_tests.rs index 4f1dfe2..da00862 100644 --- a/crates/presenter/src/host/settings/download_tests.rs +++ b/crates/presenter/src/host/settings/download_tests.rs @@ -1,7 +1,7 @@ //! 下载设置组的「**生效值**」守门(P3-5 / P3-3)。 //! //! 这一组画的是引擎真的会用的那个数,不是文件里那串字节。两者的判定在 core -//! (`DownloadTuning::from_settings`:越界/坏值回落出厂,`0` 落成「只试一次」),这里守的是 +//! (`DownloadTuning::from_raw`:越界/坏值回落出厂,`0` 落成「只试一次」),这里守的是 //! 「宿主把哪个数交给页面」以及「文件原值与生效值不一致时那句话有没有出现」。 //! //! 构造真文件:手改坏值绕不过 `store.set` 的 schema 校验,所以测试**直接写 `launcher.json`** @@ -310,3 +310,75 @@ fn the_gh_proxy_key_travels_from_the_file_to_the_core_tri_state() { ); std::fs::remove_dir_all(&prefix_dir).ok(); } + +/// **M292:三个超时键从文件走到引擎配置**(`download.throughput_floor_kib` / +/// `stall_timeout_secs` / `response_timeout_secs`)。 +/// +/// GUI 的每一条下载都经 `tuning_of` → `shared::set_tuning` → 引擎重建,所以这一段通了,慢链路用户 +/// 手改 `launcher.json` 就有自救手段。这三个键**还没有界面行**(见本文件模块头)——`tuning_of` 是 +/// 它们唯一的入口,也正是因此这里要逐字段断言,而不是只看「行上画了什么」。 +/// +/// 抓得住:① 宿主少读一个键(例如漏了 `KEY_THROUGHPUT_FLOOR`)⇒ 对应断言红;② KiB→字节换算写错 +/// (少乘 1024)⇒ 吞吐下限那条红;③ 回落口径被改成「夹到边界」⇒ 越界那条红;④(M292 返工后补强) +/// `host::engine::engine` 这一跳 —— 它内部的 `apply_tuning` 没被调用、或 `shared` 装配点没用 +/// `apply_to`,`engine.config()` 就是出厂值,三条断言全红。 +/// 抓不住:真机慢链路的实际体验(离线环境,见 M292 报告的未验项)。 +#[test] +fn the_three_timeout_keys_travel_from_the_file_to_the_engine_config() { + use std::time::Duration; + use unlucky_core::download::config::{ + KEY_CHUNK_STALL, KEY_RESPONSE_TIMEOUT, KEY_THROUGHPUT_FLOOR, + }; + + let (dir, env) = env_with( + "timeouts", + serde_json::json!({ "download": { + "throughput_floor_kib": 16, + "stall_timeout_secs": 300, + "response_timeout_secs": 180, + }}), + ); + let tuning = tuning_of(&env); + assert_eq!(tuning.min_avg_throughput, 16 * 1024); + assert_eq!(tuning.chunk_stall_timeout, Duration::from_secs(300)); + assert_eq!(tuning.response_timeout, Duration::from_secs(180)); + + // 引擎拿到的是同一组值 —— **走 GUI 的生产装配点**:`host::engine::engine`(内部先 + // `apply_tuning` → `shared::set_tuning` → 按数据根重建),不是本用例自己拼一个 `apply_to`。 + // 这一跳要是断了(宿主没把调优交给 core),下面的断言就红。 + std::fs::create_dir_all(env.layout.cache()).expect("cache 目录"); + let engine = crate::host::engine::engine(&env); + let cfg = engine.config(); + assert_eq!(cfg.min_avg_throughput, 16 * 1024); + assert_eq!(cfg.chunk_stall_timeout, Duration::from_secs(300)); + assert_eq!(cfg.response_timeout, Duration::from_secs(180)); + // 诊断行与配置同源(CLI 把它写进日志;GUI 侧这里是同一份字符串的来源)。 + assert!( + tuning.summary().contains("吞吐下限=16KiB/s"), + "{}", + tuning.summary() + ); + // 三个键名常量必须真的登记在 schema 里(写错 = 「用户设了没生效」的静默退化)。 + let schema = unlucky_core::setting::keys::launcher_schema(); + for key in [KEY_THROUGHPUT_FLOOR, KEY_CHUNK_STALL, KEY_RESPONSE_TIMEOUT] { + assert!(schema.spec(key).is_some(), "{key} 没登记"); + } + std::fs::remove_dir_all(&dir).ok(); + + // 不设 = 出厂值(这三个键没有界面行,「不设」是最常见的状态)。 + let (dir, env) = env_with("timeouts-default", serde_json::json!({})); + assert_eq!(tuning_of(&env), DownloadTuning::default()); + std::fs::remove_dir_all(&dir).ok(); + + // 非法值(0 / 越界)回落出厂,不穿透、不 panic。 + let (dir, env) = env_with( + "timeouts-bad", + serde_json::json!({ "download": { + "throughput_floor_kib": 0, + "stall_timeout_secs": 0, + "response_timeout_secs": 99_999, + }}), + ); + assert_eq!(tuning_of(&env), DownloadTuning::default()); + std::fs::remove_dir_all(&dir).ok(); +}