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
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:
commit
6e1d5fa82e
14 files changed
+1010
-236
No files matched your search
+45
-10
@@ -11,16 +11,22 @@
|
|||||||
//! ```text
|
//! ```text
|
||||||
//! <数据根>/config/launcher.json(与 `ull config set` 同一份) → setting store → DownloadTuning
|
//! <数据根>/config/launcher.json(与 `ull config set` 同一份) → setting store → DownloadTuning
|
||||||
//! (默认值/夹取/回落在 core)
|
//! (默认值/夹取/回落在 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`,
|
//! **回落是静默的**:`SettingStore::load` 永不失败(坏值只进 `problems()`);类型不对 → `None`,
|
||||||
//! 越界 → `DownloadTuning::from_settings` 的区间过滤。两种情况都落成出厂值,**不报错、不中断安装**。
|
//! 越界 → `DownloadTuning::from_raw` 的区间过滤。两种情况都落成出厂值,**不报错、不中断安装**。
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use unlucky_core::download::config::{
|
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::download::{shared, DownloadEngine};
|
||||||
use unlucky_core::setting::keys::launcher_schema;
|
use unlucky_core::setting::keys::launcher_schema;
|
||||||
@@ -28,10 +34,14 @@ use unlucky_core::setting::store::SettingStore;
|
|||||||
|
|
||||||
use crate::context::Ctx;
|
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.launcher_config()`——与 `ull config set download.concurrency` 的落点同一份文件
|
||||||
/// (设置键的落点分流见 [`Ctx::setting_path_for`])。gh-proxy 空串 = 跟随内置默认,**不是**关。
|
/// (设置键的落点分流见 [`Ctx::setting_path_for`])。gh-proxy 空串 = 跟随内置默认,**不是**关。
|
||||||
|
///
|
||||||
|
/// **本函数只做「取裸值」**:默认值、区间、回落、KiB→字节的换算全在 core 的
|
||||||
|
/// [`DownloadTuning::from_raw`](M292 起宿主不再逐个判断,也不再自己决定缺省值——那是「UI 说 8、
|
||||||
|
/// 引擎跑 64」长回来的地方)。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub(crate) fn tuning(ctx: &Ctx) -> DownloadTuning {
|
pub(crate) fn tuning(ctx: &Ctx) -> DownloadTuning {
|
||||||
let store = SettingStore::load(ctx.launcher_config(), launcher_schema());
|
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)
|
.effective_str(KEY_GH_PROXY)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string();
|
.to_string();
|
||||||
DownloadTuning::from_settings(
|
DownloadTuning::from_raw(RawTuning {
|
||||||
doc.effective_i64(KEY_CONCURRENCY),
|
concurrency: doc.effective_i64(KEY_CONCURRENCY),
|
||||||
doc.effective_i64(KEY_RETRY_LIMIT),
|
retry_limit: doc.effective_i64(KEY_RETRY_LIMIT),
|
||||||
Some(gh_proxy.as_str()),
|
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 按缓存根共享。
|
/// 建该数据根的下载引擎:参数来自 [`tuning`],引擎本体由 core 按缓存根共享。
|
||||||
|
///
|
||||||
|
/// 改过设置时往 stderr 打**一行**生效值(P21 的验收判据:改完设置得能看见引擎实际拿到了什么)。
|
||||||
|
/// 出厂值那一档不打:否则每个 `ull install` 都多一行噪声,而它什么也没告诉用户。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub(crate) fn engine(ctx: &Ctx) -> Arc<DownloadEngine> {
|
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)]
|
#[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)直接映射到这里。
|
//! 引擎配置:设置层(core::setting)直接映射到这里。
|
||||||
//!
|
//!
|
||||||
//! # 出厂默认值与设置 schema 的唯一来源(M193)
|
//! # 出厂默认值与设置 schema 的唯一来源(M193;M292 加上三个超时/阈值常量)
|
||||||
//!
|
//!
|
||||||
//! 四个常量是**唯一**写死数字的地方:schema 的默认值/范围、`EngineConfig::default()`、
|
//! 这些 `DEFAULT_* / MAX_*` 常量是**唯一**写死数字的地方:schema 的默认值/范围、
|
||||||
//! `Semaphore` 容量全从它们取。此前 schema 写 8、引擎写 64 ⇒ 调并发完全无效(引擎侧零消费者)。
|
//! `EngineConfig::default()`、`Semaphore` 容量、三个超时键的回落值全从它们取。此前 schema 写 8、
|
||||||
//! 收成常量后「改一处另一个不变」会直接变红(守卫见 `shared.rs` 与本文件的用例)。
|
//! 引擎写 64 ⇒ 调并发完全无效(引擎侧零消费者)。收成常量后「改一处另一个不变」会直接变红
|
||||||
|
//! (守卫见 `shared.rs` 与本模块 `tests.rs` 的用例)。
|
||||||
|
//!
|
||||||
|
//! # 本文件与 [`tuning`] 的分工
|
||||||
|
//!
|
||||||
|
//! * 本文件:**引擎要什么**([`EngineConfig`])、数字的出厂值与上下界、gh-proxy 三态、
|
||||||
|
//! 按数据根的调优登记处([`wanted`] / [`publish`])。
|
||||||
|
//! * [`tuning`]:**设置原文怎么变成这些数**(`DownloadTuning` / `RawTuning` / 回落与夹取)。
|
||||||
|
//! 两者拆开只为守全仓 400 行硬线,类型路径没变(下面那行 `pub use`)。
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -13,10 +21,13 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use super::backoff::BackoffPolicy;
|
use super::backoff::BackoffPolicy;
|
||||||
use super::chunked::ChunkedConfig;
|
use super::chunked::ChunkedConfig;
|
||||||
use super::region::RegionProfile;
|
|
||||||
use super::stall::{self, Deadlines};
|
use super::stall::{self, Deadlines};
|
||||||
use super::types::ResumePolicy;
|
use super::types::ResumePolicy;
|
||||||
|
|
||||||
|
mod tuning;
|
||||||
|
|
||||||
|
pub use tuning::{DownloadTuning, RawTuning};
|
||||||
|
|
||||||
/// 全局并发许可数的出厂默认(`download.concurrency` 的 schema 默认值同源)。
|
/// 全局并发许可数的出厂默认(`download.concurrency` 的 schema 默认值同源)。
|
||||||
pub const DEFAULT_MAX_CONCURRENCY: usize = 8;
|
pub const DEFAULT_MAX_CONCURRENCY: usize = 8;
|
||||||
|
|
||||||
@@ -30,10 +41,50 @@ pub const DEFAULT_MAX_ATTEMPTS_PER_SOURCE: u32 = 3;
|
|||||||
/// 每个源尝试次数的硬上限(`download.retry_limit` 的 schema 上界同源)。
|
/// 每个源尝试次数的硬上限(`download.retry_limit` 的 schema 上界同源)。
|
||||||
pub const MAX_ATTEMPTS_PER_SOURCE: u32 = 10;
|
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`,读取在宿主。只写在这里,两侧都引用常量。
|
/// 三个设置键名(层间契约):登记在 `setting::keys`,读取在宿主。只写在这里,两侧都引用常量。
|
||||||
pub const KEY_CONCURRENCY: &str = "download.concurrency";
|
pub const KEY_CONCURRENCY: &str = "download.concurrency";
|
||||||
/// 见 [`KEY_CONCURRENCY`]。
|
/// 见 [`KEY_CONCURRENCY`]。
|
||||||
pub const KEY_RETRY_LIMIT: &str = "download.retry_limit";
|
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` 的
|
/// gh-proxy 前缀覆盖键(三态语义见 [`gh_proxy_override`])。**已登记**:`setting::keys::proxy` 的
|
||||||
/// `specs()` 里有它(文本 / 出厂空串),presenter 与 CLI 的宿主都按这个常量读,读不到 ⇒
|
/// `specs()` 里有它(文本 / 出厂空串),presenter 与 CLI 的宿主都按这个常量读,读不到 ⇒
|
||||||
/// [`GhProxyOverride::Builtin`]。**绑定在哪(别当死键删)**:常量与 schema 登记的对应关系由
|
/// [`GhProxyOverride::Builtin`]。**绑定在哪(别当死键删)**:常量与 schema 登记的对应关系由
|
||||||
@@ -52,17 +103,25 @@ pub struct EngineConfig {
|
|||||||
pub connect_timeout: Duration,
|
pub connect_timeout: Duration,
|
||||||
/// 单次读超时(读空闲判定,不是整体超时),默认 30s。
|
/// 单次读超时(读空闲判定,不是整体超时),默认 30s。
|
||||||
pub read_timeout: Duration,
|
pub read_timeout: Duration,
|
||||||
/// 等响应头的上界(**单次请求**:`send()` 到响应头为止,不含正文),默认 60s。
|
/// 等响应头的上界(**单次请求**:`send()` 到响应头为止,不含正文),默认
|
||||||
|
/// [`DEFAULT_RESPONSE_TIMEOUT`](60s),可被 `download.response_timeout_secs` 改。
|
||||||
///
|
///
|
||||||
/// 取数与「为什么不是整作业总预算」见 [`super::stall`] 模块头那一处,别在调用点另写一套理由。
|
/// 取数与「为什么不是整作业总预算」见 [`super::stall`] 模块头那一处,别在调用点另写一套理由。
|
||||||
pub response_timeout: Duration,
|
pub response_timeout: Duration,
|
||||||
/// 分块作业「已这么久没有任何新字节」的判定窗口,默认 120s。
|
/// 分块作业「已这么久没有任何新字节」的判定窗口,默认 [`DEFAULT_CHUNK_STALL`](120s),
|
||||||
|
/// 可被 `download.stall_timeout_secs` 改。
|
||||||
///
|
///
|
||||||
/// 抓的是「读超时不响、但整个作业不动」的那类卡住(分块任务卡在写盘上)。
|
/// 抓的是「读超时不响、但整个作业不动」的那类卡住(分块任务卡在写盘上)。
|
||||||
pub chunk_stall_timeout: Duration,
|
pub chunk_stall_timeout: Duration,
|
||||||
/// 分块作业总期限的下限,默认 10min(实际期限取它与「按平均吞吐下限反推的时长」的较大者,
|
/// 分块作业总期限的下限,默认 10min(实际期限取它与「按平均吞吐下限反推的时长」的较大者,
|
||||||
/// 见 [`super::stall::job_budget`])。
|
/// 见 [`super::stall::job_budget`])。**没有**设置键:小作业的期限不该由用户填。
|
||||||
pub chunk_job_floor: Duration,
|
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。
|
/// 整文件重算摘要(校验阶段)的上界,默认 120s。
|
||||||
///
|
///
|
||||||
/// 正常盘上 188MB 的 SHA-1 不到 1s;这条只抓「校验被卡住」,不参与慢链路判定。
|
/// 正常盘上 188MB 的 SHA-1 不到 1s;这条只抓「校验被卡住」,不参与慢链路判定。
|
||||||
@@ -91,9 +150,10 @@ impl Default for EngineConfig {
|
|||||||
max_attempts_per_source: DEFAULT_MAX_ATTEMPTS_PER_SOURCE,
|
max_attempts_per_source: DEFAULT_MAX_ATTEMPTS_PER_SOURCE,
|
||||||
connect_timeout: Duration::from_secs(10),
|
connect_timeout: Duration::from_secs(10),
|
||||||
read_timeout: Duration::from_secs(30),
|
read_timeout: Duration::from_secs(30),
|
||||||
response_timeout: Duration::from_secs(60),
|
response_timeout: DEFAULT_RESPONSE_TIMEOUT,
|
||||||
chunk_stall_timeout: Duration::from_secs(120),
|
chunk_stall_timeout: DEFAULT_CHUNK_STALL,
|
||||||
chunk_job_floor: Duration::from_secs(600),
|
chunk_job_floor: Duration::from_secs(600),
|
||||||
|
min_avg_throughput: stall::MIN_AVG_THROUGHPUT,
|
||||||
verify_timeout: Duration::from_secs(120),
|
verify_timeout: Duration::from_secs(120),
|
||||||
permit_timeout: Duration::from_secs(120 * 60),
|
permit_timeout: Duration::from_secs(120 * 60),
|
||||||
user_agent: super::http::USER_AGENT.to_string(),
|
user_agent: super::http::USER_AGENT.to_string(),
|
||||||
@@ -119,7 +179,7 @@ impl EngineConfig {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn chunk_job_budget(&self, expected: Option<u64>) -> Duration {
|
pub fn chunk_job_budget(&self, expected: Option<u64>) -> Duration {
|
||||||
expected.map_or(self.chunk_job_floor, |total| {
|
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())
|
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`] 用)。按数据根而不是进程级:登记处本就是「一个数据根
|
/// 每个缓存根一份「宿主想要什么」([`wanted`] 用)。按数据根而不是进程级:登记处本就是「一个数据根
|
||||||
/// 一个引擎」;进程级那份在多数据根时会互相踩(A 根改 2、B 根冲回 8)。
|
/// 一个引擎」;进程级那份在多数据根时会互相踩(A 根改 2、B 根冲回 8)。
|
||||||
fn wanted_slot() -> &'static Mutex<HashMap<PathBuf, DownloadTuning>> {
|
fn wanted_slot() -> &'static Mutex<HashMap<PathBuf, DownloadTuning>> {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! `download::config` 的单测:**从 `config.rs` 拆出只为守 400 行硬线**(M289 加超时字段前先纯搬移,逐字节未改)。
|
//! `download::config` 的单测:**从 `config.rs` 拆出只为守 400 行硬线**(M289 加超时字段前先纯搬移,逐字节未改)。
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::download::region::RegionProfile;
|
||||||
use crate::setting::keys::{launcher_schema, launcher_version};
|
use crate::setting::keys::{launcher_schema, launcher_version};
|
||||||
|
|
||||||
/// **守门③**:schema 的默认值 / 区间 / 键名与 `EngineConfig::default()` / 调优默认值取同一组常量。
|
/// **守门③**: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());
|
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}");
|
||||||
|
}
|
||||||
@@ -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
|
//! **整个响应体**读取,那正好就是「慢但有效被判死」的那个坑(它的默认 `None` 也是 P19
|
||||||
//! 记的另一半事实)。每个阶段各自的界才分得清「连不上」「不回话」「不回字节」。
|
//! 记的另一半事实)。每个阶段各自的界才分得清「连不上」「不回话」「不回字节」。
|
||||||
//!
|
//!
|
||||||
//! # 「慢但有效」的适用边界(r1 复审 p2-2 要求写明)
|
//! # 「慢但有效」的适用边界(r1 复审 p2-2 要求写明;M292 起可在设置里改)
|
||||||
//!
|
//!
|
||||||
//! 第 3 条那句「慢链路照样活」有一个**前提**,等价说法是:**平均吞吐 ≥ 32 KiB/s 才活得下来**。
|
//! 第 3 条那句「慢链路照样活」有一个**前提**,等价说法是:**平均吞吐 ≥ 32 KiB/s 才活得下来**
|
||||||
//! 算术:188MB 在 20 KiB/s 上要 160.4 min,而预算只有 100.3 min ⇒ 必被杀。而且一旦被杀,
|
//! —— 32 KiB/s 是**出厂默认值**,不是不可变的事实(M292 起三个数都是设置键,见下一节)。
|
||||||
|
//! 算术:188MB 在 20 KiB/s 上要 160.4 min,而出厂预算只有 100.3 min ⇒ 必被杀。而且一旦被杀,
|
||||||
//! `DownloadError::job_budget_exhausted()` 让引擎**不再重试、也不再换源**(同一份字节、
|
//! `DownloadError::job_budget_exhausted()` 让引擎**不再重试、也不再换源**(同一份字节、
|
||||||
//! 同一条链路,候选源不可能更快)——所以在 <32 KiB/s 的链路上,这个文件大小是「下不完」,
|
//! 同一条链路,候选源不可能更快)——所以在 <32 KiB/s 的链路上,这个文件大小是「下不完」,
|
||||||
//! 不是「慢一点」。要让用户自己调这几个数,得接 `download.*` 的 schema(`setting/keys.rs`
|
//! 不是「慢一点」。
|
||||||
//! 不在 M289 的 scope,是另一个 mission);在那之前出厂值就是唯一值。
|
|
||||||
//!
|
//!
|
||||||
//! 这条边界的命中面不远:仓内就有 ≈67 KB/s 的真机观测(`font/tests/support.rs` 的文件头)。
|
//! 这条边界的命中面不远:仓内就有 ≈67 KB/s 的真机观测(`font/tests/support.rs` 的文件头)。
|
||||||
//! 真实公网慢链路的取值**没有真机验证**(M289 报告列为未验项)。
|
//! 真实公网慢链路的取值**没有真机验证**(M289 报告列为未验项,M292 也没有补)。
|
||||||
//!
|
//!
|
||||||
//! # 数字从哪来(全仓只写这一处)
|
//! # 改这几个数要付的代价(**全仓只写这一处**:键的 `doc` 与 `download::config` 都只指回这里)
|
||||||
//!
|
//!
|
||||||
//! * `response_timeout` = 60s:连接已有 10s 的 `connect_timeout` 兜底,这 60s 是**连接
|
//! 三个键(登记在 `setting::keys::download`)在引擎侧落成
|
||||||
//! 建立之后**等响应头的余额——CDN 排队、回源冷启动都在几十秒量级;响应头本身只有
|
//! `EngineConfig::{min_avg_throughput, chunk_stall_timeout, response_timeout}`,通路是
|
||||||
//! 几百字节,能回的话 60s 内必回。
|
//! `DownloadTuning::apply_to`;**不设 = 与 M289 逐值相同的出厂值**,所以「读这一节」只在真的
|
||||||
//! * `read_timeout` = 30s(既有值,不动):空闲判定。持续有字节就永远不触发。
|
//! 填了数字之后才有意义。
|
||||||
//! * `chunk_stall_timeout` = 120s:4 条连接**整体**一个新字节都没有两分钟,只可能是
|
//!
|
||||||
//! 分块任务卡住(写盘卡在杀软/网络盘上时,读超时不会响,只有这个会)。
|
//! * **把吞吐下限调低**(`download.throughput_floor_kib`):慢链路能下完 —— 20 KiB/s 的用户把
|
||||||
//! * [`MIN_AVG_THROUGHPUT`] = 32 KiB/s:分块作业的总期限 = 总字节 / 32 KiB/s
|
//! 它设成 16,188MB 的预算从 100.3 min 变成 201 min。代价是「真挂住」的作业也被允许占着许可
|
||||||
//! (188MB ≈ 100 分钟)。适用边界与后果见上一节——**这是「≥32 KiB/s 才活得下来」
|
//! **更久**:总期限是 `总字节 / 下限`,下限减半 ⇒ 期限翻倍。它换到的是「慢」而不是「无界」:
|
||||||
//! 的等价说法**,不是「慢一点的链路也没事」。
|
//! 字节完全不动仍然由 `stall_timeout_secs` 那一档兜(那是 `NoProgress`,与总期限是两个原因)。
|
||||||
//! * `permit_timeout` = 120min:等许可的上界。它必须 **≥ 单个作业的总期限**
|
//! * **把无进展窗口 / 单次请求上界调大**(`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` 时正常排队
|
//! (最坏 ≈ 100 min 预算 + 120s 校验),否则 `download.concurrency = 1` 时正常排队
|
||||||
//! 会被误杀;它挡的是「许可被永久占住」(M289 之前每一处无界 `await` 都能造出来)。
|
//! 会被误杀;它挡的是「许可被永久占住」(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::fmt;
|
||||||
use std::time::Duration;
|
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;
|
pub const MIN_AVG_THROUGHPUT: u64 = 32 * 1024;
|
||||||
|
|
||||||
/// 卡在哪一段(诊断字段;`as_str` 是稳定 id,`label` 是给人看的中文)。
|
/// 卡在哪一段(诊断字段;`as_str` 是稳定 id,`label` 是给人看的中文)。
|
||||||
@@ -291,88 +323,21 @@ pub struct Deadlines {
|
|||||||
pub read_idle: Duration,
|
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]
|
#[must_use]
|
||||||
pub fn job_budget(total: u64, floor: Duration) -> Duration {
|
pub fn job_budget(total: u64, floor: Duration, min_throughput: u64) -> Duration {
|
||||||
floor.max(Duration::from_secs(total / MIN_AVG_THROUGHPUT))
|
// `total / 1 = total` 秒在 `Duration` 里装得下(u64 秒 ≈ 5840 亿年),不会溢出。
|
||||||
|
floor.max(Duration::from_secs(total / min_throughput.max(1)))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "stall/tests.rs"]
|
||||||
use super::{job_budget, Deadlines, StallReason, StallReport, StallStage, MIN_AVG_THROUGHPUT};
|
mod tests;
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -37,8 +37,9 @@ pub const JAVA_INSTALL_ROOT_UNSET: &str = "";
|
|||||||
/// 必须同一份集合(setting 层不依赖 game 层),一致性由 `tests/setting/java_install_keys.rs` 守。
|
/// 必须同一份集合(setting 层不依赖 game 层),一致性由 `tests/setting/java_install_keys.rs` 守。
|
||||||
pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu", "termux"];
|
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 font;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
pub mod update;
|
pub mod update;
|
||||||
@@ -59,6 +60,9 @@ pub mod update;
|
|||||||
/// `1.7.0` 登记 M168 的中文字体下载地址覆盖键(`ui.font_cdn`,见 [`font`]):纯增量、无迁移。
|
/// `1.7.0` 登记 M168 的中文字体下载地址覆盖键(`ui.font_cdn`,见 [`font`]):纯增量、无迁移。
|
||||||
/// `1.8.0` 登记 M197 的 gh-proxy 前缀覆盖键(`download.gh_proxy`):同样纯增量——出厂值是空串,
|
/// `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 的文件时,
|
/// **旧读新(方向相反,措辞必须精确)**:1.4.0 的客户端读 1.5.0 的文件时,
|
||||||
/// [`super::schema::SchemaCompat::check_url`] 走 `found.minor > expected.minor` →
|
/// [`super::schema::SchemaCompat::check_url`] 走 `found.minor > expected.minor` →
|
||||||
@@ -68,7 +72,7 @@ pub mod update;
|
|||||||
/// 原因:回退到旧版本会出现「设置改不动」,而原因只在 `$schema` 那一行里,不看版本号无从排查。
|
/// 原因:回退到旧版本会出现「设置改不动」,而原因只在 `$schema` 那一行里,不看版本号无从排查。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn launcher_version() -> SchemaVersion {
|
pub fn launcher_version() -> SchemaVersion {
|
||||||
SchemaVersion::new(1, 8, 0)
|
SchemaVersion::new(1, 9, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `config/launcher.json` 的 schema。
|
/// `config/launcher.json` 的 schema。
|
||||||
@@ -106,34 +110,9 @@ pub fn launcher_schema() -> SettingSchema {
|
|||||||
AiPolicy::sensitive(),
|
AiPolicy::sensitive(),
|
||||||
"实例默认隔离模式:isolated(运行目录派生 versions/<版本 id>,D20 默认)/ shared(所有实例共用 .minecraft 根)",
|
"实例默认隔离模式:isolated(运行目录派生 versions/<版本 id>,D20 默认)/ shared(所有实例共用 .minecraft 根)",
|
||||||
))
|
))
|
||||||
.with(SettingSpec::int(
|
// M292:下载调优四键(并发 / 单源尝试次数 / 地区 / 镜像偏好)搬到 [`download`]——主表这一行
|
||||||
"download.concurrency",
|
// 代替原先四条 `.with(..)`,登记顺序与搬到那边之前逐项相同。
|
||||||
crate::download::config::DEFAULT_MAX_CONCURRENCY as i64,
|
.with_all(download::specs())
|
||||||
(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(镜像优先)",
|
|
||||||
))
|
|
||||||
// M197:代理与 gh-proxy 三键(`download.proxy_mode` / `proxy_url` / `gh_proxy`)搬到
|
// M197:代理与 gh-proxy 三键(`download.proxy_mode` / `proxy_url` / `gh_proxy`)搬到
|
||||||
// [`proxy`]——主表这一段的注释也一起搬过去,见那个模块的表格。
|
// [`proxy`]——主表这一段的注释也一起搬过去,见那个模块的表格。
|
||||||
.with_all(proxy::specs())
|
.with_all(proxy::specs())
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,8 +131,8 @@ fn appearance_keys_are_all_ai_writable_and_registered() {
|
|||||||
fn schema_registration_shape_is_right() {
|
fn schema_registration_shape_is_right() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
launcher_version().minor,
|
launcher_version().minor,
|
||||||
8,
|
9,
|
||||||
"1.8.0 登记 M197 的 download.gh_proxy(1.7.0 是 M168 的 ui.font_cdn)"
|
"1.9.0 登记 M292 的三个下载超时键(1.8.0 是 M197 的 download.gh_proxy,1.7.0 是 M168 的 ui.font_cdn)"
|
||||||
);
|
);
|
||||||
let schema = launcher_schema();
|
let schema = launcher_schema();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -128,10 +128,11 @@ fn overrides_key_is_an_enum_whitelist() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn schema_versions_register_the_new_keys_without_migration() {
|
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.5.0 = M116 窗口圆角,1.4.0 = M91 外观下层三键,1.3.0 = D17「外观(高级)」,
|
||||||
// 1.2.0 = D23 的 JDK 落点/优先级键)。
|
// 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!(instance_version(), SchemaVersion::new(1, 1, 0));
|
||||||
assert_eq!(launcher_schema().id(), SchemaId::LAUNCHER);
|
assert_eq!(launcher_schema().id(), SchemaId::LAUNCHER);
|
||||||
// major 仍是 1:迁移链(按 major 走)不需要新步骤,老文件缺键即默认值。
|
// major 仍是 1:迁移链(按 major 走)不需要新步骤,老文件缺键即默认值。
|
||||||
|
|||||||
@@ -66,10 +66,11 @@ fn install_root_accepts_a_path_and_keeps_empty_as_unset() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn schema_version_bumps_minor_only_so_no_migration_step_is_needed() {
|
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 是**纪律**,
|
// 1.6.0 是 M161 的自更新两键,1.5.0 是 M116 窗口圆角)。每加一批键推一次 minor 是**纪律**,
|
||||||
// 不是巧合:老版本读新文件会判 `ReadOnlyPreserveSchema`,所以版本号本身就是「新键存在」的对外声明。
|
// 不是巧合:老版本读新文件会判 `ReadOnlyPreserveSchema`,所以版本号本身就是「新键存在」的对外声明。
|
||||||
assert_eq!(launcher_version(), SchemaVersion::new(1, 8, 0));
|
assert_eq!(launcher_version(), SchemaVersion::new(1, 9, 0));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
launcher_version().major,
|
launcher_version().major,
|
||||||
1,
|
1,
|
||||||
|
|||||||
@@ -13,10 +13,16 @@
|
|||||||
//! ```text
|
//! ```text
|
||||||
//! config/launcher.json ←── store.set(`settings::set`,本模块只读)
|
//! config/launcher.json ←── store.set(`settings::set`,本模块只读)
|
||||||
//! download.concurrency ─┐
|
//! download.concurrency ─┐
|
||||||
//! download.retry_limit ─┼─→ DownloadTuning(core 决定默认值与夹取)─→ shared::set_tuning
|
//! download.retry_limit ─┤
|
||||||
//! download.gh_proxy ───┘ (引擎重建时生效)
|
//! 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` 分支里,
|
//! **调优的同步点是显式的**:`host/engine.rs` 每次取引擎前、`dispatch.rs` 的 `SetSetting` 分支里,
|
||||||
//! 都调 [`apply_tuning`](幂等:值没变就什么都不做)。它**不是**藏在某个读函数里的副作用——
|
//! 都调 [`apply_tuning`](幂等:值没变就什么都不做)。它**不是**藏在某个读函数里的副作用——
|
||||||
//! 「读起来是纯读、实际改全局状态」正是本仓吃过亏的那类形状。
|
//! 「读起来是纯读、实际改全局状态」正是本仓吃过亏的那类形状。
|
||||||
@@ -241,20 +247,29 @@ fn gh_proxy_state(state: &GhProxyOverride) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 从 `launcher.json` 读出下载调优(默认值与夹取口径在 core,本函数只做「取原始值」)。
|
/// 从 `launcher.json` 读出下载调优(默认值与夹取口径在 core,本函数只做「取原始值」)。
|
||||||
|
///
|
||||||
|
/// M292:六个键一起读(并发 / 单源尝试 / gh-proxy / 吞吐下限 / 无进展窗口 / 单次请求上界),
|
||||||
|
/// 缺省与越界一律由 core 的 [`DownloadTuning::from_raw`] 回落——本函数不判断任何区间。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub(crate) fn tuning_of(env: &HostEnv) -> DownloadTuning {
|
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 store = env.store();
|
||||||
let doc = store.doc();
|
let doc = store.doc();
|
||||||
let gh = doc
|
let gh = doc
|
||||||
.effective_str(KEY_GH_PROXY)
|
.effective_str(KEY_GH_PROXY)
|
||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
DownloadTuning::from_settings(
|
DownloadTuning::from_raw(RawTuning {
|
||||||
doc.effective_i64(KEY_CONCURRENCY),
|
concurrency: doc.effective_i64(KEY_CONCURRENCY),
|
||||||
doc.effective_i64(KEY_RETRY_LIMIT),
|
retry_limit: doc.effective_i64(KEY_RETRY_LIMIT),
|
||||||
Some(gh.as_str()),
|
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 的下载调优(按数据根),返回**是否真的变了**(变了就失效该根的引擎)。
|
/// 把当前设置灌进 core 的下载调优(按数据根),返回**是否真的变了**(变了就失效该根的引擎)。
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! 下载设置组的「**生效值**」守门(P3-5 / P3-3)。
|
//! 下载设置组的「**生效值**」守门(P3-5 / P3-3)。
|
||||||
//!
|
//!
|
||||||
//! 这一组画的是引擎真的会用的那个数,不是文件里那串字节。两者的判定在 core
|
//! 这一组画的是引擎真的会用的那个数,不是文件里那串字节。两者的判定在 core
|
||||||
//! (`DownloadTuning::from_settings`:越界/坏值回落出厂,`0` 落成「只试一次」),这里守的是
|
//! (`DownloadTuning::from_raw`:越界/坏值回落出厂,`0` 落成「只试一次」),这里守的是
|
||||||
//! 「宿主把哪个数交给页面」以及「文件原值与生效值不一致时那句话有没有出现」。
|
//! 「宿主把哪个数交给页面」以及「文件原值与生效值不一致时那句话有没有出现」。
|
||||||
//!
|
//!
|
||||||
//! 构造真文件:手改坏值绕不过 `store.set` 的 schema 校验,所以测试**直接写 `launcher.json`**
|
//! 构造真文件:手改坏值绕不过 `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();
|
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();
|
||||||
|
}
|
||||||
Reference in new issue
Block a user