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:
1 parent
f4b5a73594
commit
2ecb33afae
14 files changed
+950
-211
No files matched your search
+45
-10
@@ -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)]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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,221 @@ 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` 里任意一行赋值删掉 ⇒ 对应的 `assert_eq!` 红;把 `from_raw` 里
|
||||
/// KiB→字节的 `* 1024` 删掉 ⇒ 吞吐下限那条红(值差 1024 倍);把 `chunk_job_budget` 改回用
|
||||
/// `MIN_AVG_THROUGHPUT` 常量 ⇒ 最后两条期限断言红(改设置对期限无效)。
|
||||
#[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}");
|
||||
}
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
@@ -60,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` →
|
||||
@@ -69,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。
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
//! `download.*` 的基础四键(M292 从 `keys.rs` 纯搬移拆出:那边 396 行、贴着 400 行硬线)。
|
||||
//! `download.*` 的七个键:四个基础键(M292 从 `keys.rs` 纯搬移拆出:那边 396 行、贴着 400 行硬线)
|
||||
//! + M292 新增的三个超时/阈值键。
|
||||
//!
|
||||
//! 搬移范围与顺序都未改:并发 / 单源尝试次数 / 地区判定 / 镜像偏好,四项逐字搬来,主表只留
|
||||
//! `.with_all(download::specs())` 一行。语义与「谁消费」仍写在各自的 `doc` 里(那是给界面与
|
||||
//! AI 的 JSON Schema `description`,不是本文件的注释),所以这里不再抄第二份。
|
||||
//!
|
||||
//! 与 [`super::proxy`] 的分工:代理 / gh-proxy 三个键在那边(它们问的是「怎么把包拿到手」,
|
||||
//! 而本文件这四个问的是「怎么排队、怎么重试、从哪个源拿」)。M292 新增的三个超时键
|
||||
//! (吞吐下限 / 无进展窗口 / 单次请求上界)也登记在本文件,它们与并发、重试同属「下载调优」,
|
||||
//! 而**改它们的代价**只写在一处:`core::download::stall` 的模块头。
|
||||
//! 而本文件这些问的是「怎么排队、怎么重试、从哪个源拿、允许慢到什么程度」)。
|
||||
//!
|
||||
//! **三个超时键的默认值与 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())`)。
|
||||
/// 七个键的完整登记项(主表一行 `.with_all(download::specs())`)。
|
||||
#[must_use]
|
||||
pub fn specs() -> Vec<SettingSpec> {
|
||||
vec![
|
||||
@@ -45,5 +51,68 @@ pub fn specs() -> Vec<SettingSpec> {
|
||||
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",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user