feat(download): download.* 三个超时/阈值键接进 schema 与引擎(M292 2/2)

P21 第 1 条:M289 的四个超时/阈值只挂在 `EngineConfig`,`DownloadTuning::apply_to` 只搬并发与
每源尝试次数 ⇒ 慢链路用户没有任何自救手段。出厂等价于「平均吞吐 ≥32 KiB/s 才活得下来」:
188MB 在 20 KiB/s 上要 160min > 100.3min 预算,必被杀;被杀后不再重试、不再换源,所以是
「下不完」而不是「慢一点」。

## 键(schema 1.9.0,新键推 minor)

| 键 | 默认 | 范围 | 落到 |
|---|---|---|---|
| `download.throughput_floor_kib` | 32 KiB/s | 4~4096 | `EngineConfig::min_avg_throughput`(×1024 → 字节/秒)|
| `download.stall_timeout_secs` | 120s | 30~600 | `EngineConfig::chunk_stall_timeout` |
| `download.response_timeout_secs` | 60s | 10~600 | `EngineConfig::response_timeout` |

三个默认值与 M289 的硬编码**逐值相等** ⇒ 不设 = 当前行为。上界(`MAX_*` 常量)就是「把挂住变成
无界」那道兜底:越界值在写入口被 schema 拒、引擎侧再回落一次出厂值。

## 通路

`RawTuning`(宿主只按 `KEY_*` 常量取裸值)→ `DownloadTuning::from_raw`(默认/区间/回落/KiB→字节
全在 core 一处)→ `apply_to` → `EngineConfig` → `stall::job_budget(total, floor, min_throughput)`。
`job_budget` 内部 `min_throughput.max(1)` 挡除零(设置层已拒 0,这是纵深防御)。

两个宿主都读六个键:`crates/cli/src/download.rs::tuning`、`presenter::host::settings::download::
tuning_of`。CLI 在**非出厂值**时往 stderr 打一行 `DownloadTuning::summary()`(`--dry-run` 也会经过
它;实测 `ull --data-dir … java install 21 --dry-run` 的 stderr 是
「下载调优:并发=8 单源尝试=3 吞吐下限=16KiB/s 无进展窗口=300s 单次请求上界=180s gh_proxy=跟随内置默认」,
出厂/非法值时一行都不打)。

## 文档口径(各只写一处)

- `stall.rs` 模块头:「改这几个数要付的代价」(调低下限换到的是「慢」不是「无界」;两档窗口是
  单次尝试的界,最坏要乘候选数×尝试次数)+「数字从哪来」改成「默认值 / 能不能改」+ 补上 P21
  第 3 条要的「`permit_timeout` 是按一次尝试反推,不是一个作业」。

## 两个纯搬移(为 400 行硬线)

- `config.rs` 423 → 261:`DownloadTuning` / `RawTuning` / `secs_in_range` / `impl` 整段搬到
  `config/tuning.rs`(194 行),`pub use tuning::{DownloadTuning, RawTuning};` 保住原路径;
  搬移块 172 行原样 diff = 0(无归一化)。
- `stall.rs` 440 → 343:测试搬到 `stall/tests.rs`(103 行),唯一变换是整体去 4 格缩进;两个
  未改动的用例函数体与 HEAD 逐字符(去空白)相同。

## 用例

core(`config/tests.rs` + `stall/tests.rs` + `keys/download.rs`):默认值与 schema/引擎三处同源、
改设置 ⇒ 引擎配置与总期限都变、0 与越界回落且不除零、doc 里的数字、`summary()` 的生效值、键顺序。
cli:生产入口 `build_engine` 拿到三个新值 + `announce` 只在非出厂值时给行 + 非法值回落。
presenter:文件 → `tuning_of` → `EngineConfig` 三段一致 + 键名常量与 schema 登记绑定。
This commit is contained in:
AskaEth committed 2026-11-13 03:35:08 +08:00
1 parent f4b5a73594
commit 2ecb33afae
14 files changed
+950 -211

No files matched your search

+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,69 @@ 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 报告的未验项)。
#[test]
fn the_three_timeout_keys_travel_from_the_file_to_the_engine_config() {
use std::time::Duration;
use unlucky_core::download::config::{
EngineConfig, 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));
// 引擎拿到的是同一组值(`apply_to` 是装配点唯一的一跳)。
let cfg = tuning.apply_to(EngineConfig::default());
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();
}