Files
Unlucky-Launcher/crates/presenter/src/host/settings/download.rs
T
AskaEth 2ecb33afae 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 登记绑定。
2026-11-13 03:35:08 +08:00

285 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 设置页「下载」分组 + 下载调优的**宿主侧翻译**(M193)。
//!
//! ## 为什么单开一个文件
//!
//! 同 `host/settings_tests.rs` 的理由:`host/settings.rs` 正好顶在 400 行硬线上,而本轮要往这一组加
//! 「`download.gh_proxy` 三态行 + 并发硬上限说明 + 调优翻译」约 24 行。不拆就得砍 `settings.rs` 里
//! 那些**留着防复发**的论证注释(`ai_group` 的 M174 回归说明、`parse_value` 的 P1-1 浮点键事故)
//! ——那比多一个文件糟得多。搬出来还有一个附带好处:这一组是**唯一**要往 core 灌下载调优的一组,
//! 代码与它的守卫(`settings_tests.rs`)现在挨在一起。
//!
//! ## 谁在读 / 谁在写
//!
//! ```text
//! config/launcher.json ←── store.set(`settings::set`,本模块只读)
//! download.concurrency ─┐
//! 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`](幂等:值没变就什么都不做)。它**不是**藏在某个读函数里的副作用——
//! 「读起来是纯读、实际改全局状态」正是本仓吃过亏的那类形状。
use unlucky_core::download::config::{
DownloadTuning, GhProxyOverride, DEFAULT_MAX_ATTEMPTS_PER_SOURCE, DEFAULT_MAX_CONCURRENCY,
MAX_CONCURRENCY,
};
use unlucky_core::download::shared;
use crate::dto::{SettingGroupView, SettingRowView};
use super::super::env::HostEnv;
use super::{selected, text};
/// 并发数的推荐档位(`selected()` 找不到时回 0;手改出来的非档位值会被插进选项表,见 [`group`])。
const CONCURRENCY_LADDER: [&str; 5] = ["4", "8", "16", "32", "64"];
/// 单源尝试次数的推荐档位(`0` 是旧版 GUI 提供过的档位,语义 = 只试一次,见 schema 描述)。
const RETRY_LADDER: [&str; 6] = ["0", "1", "2", "3", "5", "10"];
/// 下载(D7):并发/重试/地区/镜像偏好/代理/GitHub 加速前缀。
///
/// ## P3-5:这两行显示的是**引擎生效值**,不是文件原值
///
/// `download.concurrency` / `download.retry_limit` 的生效判定在 core([`DownloadTuning`]):
/// 越界、负数、手改坏值一律**回落出厂值**(不夹到边界——「想 9999 给 64」仍然是界面说谎)。
/// 所以这一组必须画 [`tuning_of`] 的结论;文件里的原值只在**两者不一致**时作为一句解释出现
/// (「原值 9999 已按出厂 8 生效」)。判定的两个输入都是**现读**的:文件原值取自
/// `doc().get(..)`(显式写过的值),生效值取自 core 的翻译层 —— 没有一个数字来自常量或参数。
pub(super) fn group(env: &HostEnv) -> SettingGroupView {
let tuning = tuning_of(env);
let concurrency = (tuning.max_concurrency as i64).to_string();
let retry = i64::from(tuning.max_attempts_per_source).to_string();
let concurrency_note = applied_note(
raw_text(env, unlucky_core::download::config::KEY_CONCURRENCY),
tuning.max_concurrency as i64,
DEFAULT_MAX_CONCURRENCY as i64,
);
let retry_note = applied_note(
raw_text(env, unlucky_core::download::config::KEY_RETRY_LIMIT),
i64::from(tuning.max_attempts_per_source),
i64::from(DEFAULT_MAX_ATTEMPTS_PER_SOURCE),
);
let concurrency_options = ladder(&CONCURRENCY_LADDER, &concurrency);
let retry_options = ladder(&RETRY_LADDER, &retry);
let concurrency_options: Vec<&str> = concurrency_options.iter().map(String::as_str).collect();
let retry_options: Vec<&str> = retry_options.iter().map(String::as_str).collect();
let regions = ["auto", "cn", "global"];
let mirrors = ["auto", "official", "mirror"];
let proxies = ["none", "system", "manual"];
let region_value = text(env, "download.region");
let mirror_value = text(env, "download.mirror_preference");
let proxy_value = text(env, "download.proxy_mode");
SettingGroupView::new("download", "下载与镜像(D7)")
.with(SettingRowView::choice(
unlucky_core::download::config::KEY_CONCURRENCY,
"并发数",
&with_note(
format!(
"同时下载的文件数:出厂 {DEFAULT_MAX_CONCURRENCY},可调 1~{MAX_CONCURRENCY}(引擎硬上限,防止填 9999 把机器拖死);越界或手改坏值按出厂值生效"
),
concurrency_note.as_deref(),
),
&concurrency_options,
selected(&concurrency_options, &concurrency),
))
.with(SettingRowView::choice(
unlucky_core::download::config::KEY_RETRY_LIMIT,
"单源尝试次数",
&with_note(
format!(
"同一个源最多试几次(含首次;出厂 {DEFAULT_MAX_ATTEMPTS_PER_SOURCE})。0 与 1 都等于「只试一次、不重试」——0 是旧版提供的档位,不代表「不下载」。换源不受此限"
),
retry_note.as_deref(),
),
&retry_options,
selected(&retry_options, &retry),
))
.with(SettingRowView::choice(
"download.region",
"地区判定",
"auto = 时区+语言+探测;决定是否优先国内镜像",
&regions,
selected(
&regions,
if region_value.is_empty() {
"auto"
} else {
&region_value
},
),
))
.with(SettingRowView::choice(
"download.mirror_preference",
"镜像偏好",
"auto = 按健康度评分;official = 官方源优先",
&mirrors,
selected(
&mirrors,
if mirror_value.is_empty() {
"auto"
} else {
&mirror_value
},
),
))
.with(SettingRowView::choice(
"download.proxy_mode",
"代理模式",
"none / system(跟随系统)/ manual(下面填地址)",
&proxies,
selected(
&proxies,
if proxy_value.is_empty() {
"none"
} else {
&proxy_value
},
),
))
.with(SettingRowView::text(
"download.proxy_url",
"HTTP 代理",
"manual 模式生效;留空则用系统代理",
&text(env, "download.proxy_url"),
))
// M193:gh-proxy 三态。**留空与 off 是两件事**,描述里必须都写出来——用户最容易把
// 「我还没决定」当成「我不要加速」,于是以为自己关掉了、实际还在套前缀。
//
// P3-5:末尾那半句是**引擎生效档位**(`tuning.gh_proxy` 的翻译结论),不是文件原文。
.with(SettingRowView::text(
unlucky_core::download::config::KEY_GH_PROXY,
"GitHub 加速前缀(gh-proxy)",
&format!(
"留空 = 跟随内置默认(镜像优先/国内判定时自动套 gh-proxy.com);填 off = 关闭(不生成任何 gh-proxy 候选);填一个 URL 前缀 = 覆盖它;当前生效:{}",
gh_proxy_state(&tuning.gh_proxy)
),
&text(env, unlucky_core::download::config::KEY_GH_PROXY),
))
.with(SettingRowView::action(
super::super::engine::DETECT_REGION_ACTION,
"重新检测地区",
"清掉 24h 地区缓存,下次下载重新判定",
"detect_region",
))
}
/// 推荐档位 + 「当前值不在档位里就把它插进去」(否则这一行会把 2 显示成 4 —— 又一个假值)。
fn ladder(ladder: &[&str], current: &str) -> Vec<String> {
let mut options: Vec<String> = ladder.iter().map(|item| (*item).to_string()).collect();
if !current.is_empty() && !options.iter().any(|option| option == current) {
options.push(current.to_string());
options.sort_by_key(|option| option.parse::<i64>().unwrap_or(i64::MAX));
}
options
}
/// 文件里**显式写过**的原值,转成**上屏文本**(`None` = 文件没写这个键)。
///
/// 与 `super::int` 的区别:那个走 `effective()`(文件值 → schema 默认值),本函数只问文件本身。
/// 「原值 X 已按出厂 N 生效」这句话的两个数必须现读(一个来自文件、一个来自 core 的翻译层),
/// **不许**拿常量或请求参数冒充——那是 U1 明令禁止的假证据。
///
/// ## 为什么返回字符串而不是 `i64`(r1 代码复审 p2)
///
/// 手改文件可以把一个整数键写成 `"auto"` / `8.5` / `true`:`document::load` 对坏值只记
/// `LoadProblem`、原样留在 `values` 里,所以 `doc().get(key)` 真的能拿到 `Some(String("auto"))`。
/// 早先这里用 `Value::as_i64`,非整数一律 `None` ⇒ 行上画着生效值 8,却**不告诉用户他写的
/// `auto` 被忽略了**——「设置项说假话」的最后一个洞。改成上屏文本后,越界整数(`9999` / `-4`)、
/// 非整数 JSON(`8.5`)、别的类型(`true`)、字符串(`auto`)**都能进那句提示**;
/// 数字 `to_string()` 出来仍是 `9999`,所以旧断言与旧措辞一字未变。
fn raw_text(env: &HostEnv, key: &str) -> Option<String> {
use serde_json::Value;
let store = env.store();
match store.doc().get(key)? {
// `null` 等于「没写」:`effective()` 也是这么当的(`filter(|value| !value.is_null())`)。
Value::Null => None,
// 字符串照原文上屏(不要 serde 的引号):用户写的是 `auto`,提示里就该是 `auto`。
Value::String(text) => Some(text.clone()),
other => Some(other.to_string()),
}
}
/// 文件原值 ≠ 生效值时的说明;`None` = 没什么可说的(没写过,或写的就是生效值)。
///
/// `raw` 是**上屏文本**(见 [`raw_text`]),`effective` / `factory` 是 core 翻译层的结论。
fn applied_note(raw: Option<String>, effective: i64, factory: i64) -> Option<String> {
let raw = raw?;
// 比的是「上屏文本」:文件写 `8`、生效也是 8 ⇒ 没什么可说的(`8.0` 会照实提示,因为那是一次
// 类型不一致的坏值)。
if raw == effective.to_string() {
return None;
}
Some(if effective == factory {
format!("原值 {raw} 已按出厂 {factory} 生效")
} else {
format!("原值 {raw} 已按 {effective} 生效")
})
}
/// 说明 +(有差异时)那一句「原值 … 生效」。
///
/// **那句结论独占一行(M201 r1 视觉复审 P2-2)**:原先用「;」接在说明末尾,于是在设置页 424 列的
/// 表单宽里,越界那句的最后一个「效」被挤到第三行、单字成行(浅色 1180 / 深色 1180 / 窄窗 880
/// 三张都是同一处孤字,x 396–407)。那句话恰好只在本 mission 的主场景(手改坏值/越界)出现,
/// 让「用户最需要看懂的那一句」排得最难看。改法**不动排版**(换行由渲染器按 `\n` 分行),
/// 只把结论从段落里挪出来——两个数(文件原值 / 生效值)与出厂/非出厂的两种措辞一个字都没丢。
fn with_note(base: String, note: Option<&str>) -> String {
match note {
Some(note) => format!("{base}\n{note}"),
None => base,
}
}
/// gh-proxy 三态的上屏短句(与 [`GhProxyOverride`] 一一对应)。
fn gh_proxy_state(state: &GhProxyOverride) -> String {
match state {
GhProxyOverride::Builtin => "跟随内置默认".to_string(),
GhProxyOverride::Off => "已关闭(off)".to_string(),
GhProxyOverride::Prefix(prefix) => format!("覆盖为 {prefix}"),
}
}
/// 从 `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::{
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_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 的下载调优(按数据根),返回**是否真的变了**(变了就失效该根的引擎)。
///
/// 幂等:值没变时 `shared::set_tuning` 一行不动,所以「每次取引擎前都调」是安全的。
pub(crate) fn apply_tuning(env: &HostEnv) -> bool {
shared::set_tuning(env.layout.cache(), tuning_of(env))
}
#[cfg(test)]
#[path = "download_tests.rs"]
mod tests;