Merge branch 'feat/m292-dlsettings-wire-download-timeouts-i'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
GPU evidence / GPU 证据 — 真实产物判据 (push) Canceled after 0s
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Canceled after 0s
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Canceled after 0s
Dev Guards / GPU 证据门禁(同 main 口径;无 GPU 只跑自测) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s

This commit is contained in:
AskaEth committed 2026-11-13 04:29:11 +08:00
commit 6e1d5fa82e
14 files changed
+1010 -236

No files matched your search

+45 -10
View File
@@ -11,16 +11,22 @@
//! ```text
//! <数据根>/config/launcher.json(与 `ull config set` 同一份) → setting store → DownloadTuning
//! (默认值/夹取/回落在 core)
//! → shared::engine_with_tuning(<cache 根>, tuning) → 引擎的 Semaphore / attempts_for()
//! → shared::engine_with_tuning(<cache 根>, 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<String> {
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<DownloadEngine> {
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)]
+79
View File
@@ -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());
}
+71 -82
View File
@@ -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<u64>) -> 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<i64>,
attempts: Option<i64>,
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<HashMap<PathBuf, DownloadTuning>> {
+223
View File
@@ -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}");
}
+194
View File
@@ -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<i64>,
/// [`KEY_RETRY_LIMIT`](super::KEY_RETRY_LIMIT)。
pub retry_limit: Option<i64>,
/// [`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<i64>,
/// [`KEY_CHUNK_STALL`](super::KEY_CHUNK_STALL)(秒)。
pub stall_timeout_secs: Option<i64>,
/// [`KEY_RESPONSE_TIMEOUT`](super::KEY_RESPONSE_TIMEOUT)(秒)。
pub response_timeout_secs: Option<i64>,
}
/// `min..=max` 之间的秒数 → `Duration`(负数 / `0` / 越界一律 `None`,由调用方回落出厂值)。
fn secs_in_range(raw: Option<i64>, min: u32, max: u32) -> Option<Duration> {
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<i64>,
attempts: Option<i64>,
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)),
}
}
}
+63 -98
View File
@@ -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;
+103
View File
@@ -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));
}
+10 -31
View File
@@ -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())
+118
View File
@@ -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<SettingSpec> {
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",
]
);
}
}
+2 -2
View File
@@ -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!(
+3 -2
View File
@@ -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 走)不需要新步骤,老文件缺键即默认值。
@@ -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,
+23 -8
View File
@@ -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 的下载调优(按数据根),返回**是否真的变了**(变了就失效该根的引擎)。
@@ -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();
}