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

+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());
}