Merge branch 'feat/m194-dlmsettings-wire-download-concurren'
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
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
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
This commit is contained in:
commit
9bb9855392
15 files changed
+1012
-190
No files matched your search
@@ -1,9 +1,25 @@
|
|||||||
//! 设置页「Downloads」分组:首选源 / 并发数 / 校验(演示行)。
|
//! 设置页「Downloads」分组:并发 / 单源尝试次数 / GitHub 加速前缀(M193)。
|
||||||
//!
|
//!
|
||||||
//! **这一组归 download 线**:接真控件时在本文件把 `body` 换成 `Some(download::body)`(渲染器也写在本文件),
|
//! **这一组归 download 线**:接真控件时在本文件把 `body` 换成 `Some(download::body)`(渲染器也写在本文件),
|
||||||
//! `settings/mod.rs` 的注册表不用动。
|
//! `settings/mod.rs` 的注册表不用动。
|
||||||
//!
|
//!
|
||||||
//! 注意与下载页(`pages/download*`)的区别:那是「装哪个版本」,这里是「怎么下」的默认值。
|
//! 注意与下载页(`pages/download*`)的区别:那是「装哪个版本」,这里是「怎么下」的默认值。
|
||||||
|
//!
|
||||||
|
//! ## 为什么这三行是「说语义」而不是「说当前值」(M193)
|
||||||
|
//!
|
||||||
|
//! 原来这三行是**写死的演示值**(`("Concurrency", "8 threads")` / `("Preferred source",
|
||||||
|
//! "Automatic (region)")` / `("Verify checksums", "On")`)。用户实测把它们当成真值投诉过
|
||||||
|
//! (「它默认显示 8,可我改成 2 也没变」)——因为 iced 侧**根本没有设置数据通路**:
|
||||||
|
//! `pages::Model` 没有设置快照、`grep -rn "SettingsChanged" crates/app-iced/src` 零命中、
|
||||||
|
//! [`State`] 是个空 struct 且无人填。而这三行里「8 threads」当时还是**假的**:引擎真值曾是 64
|
||||||
|
//! (schema 说 8),「Verify checksums」更是没有任何校验开关(校验恒开)。
|
||||||
|
//!
|
||||||
|
//! 所以这一轮把假值换成**不会撒谎的语义行**:区间、默认值、「留空 / off / 前缀」三态各是什么。
|
||||||
|
//! 真值(用户在 `launcher.json` 里填的那个数)不在这里显示——**宁可不说,也不说假的**。
|
||||||
|
//! 真正的可改控件要等一条 presenter 快照 → app → `Groups.download` 的数据通路,另立 mission。
|
||||||
|
//!
|
||||||
|
//! **生效保证**:这三条设置真的会被引擎读走(`core::download::shared::set_tuning`,宿主侧
|
||||||
|
//! 翻译在 `presenter::host::settings`),所以「行里的语义」与「引擎行为」是同一份契约。
|
||||||
|
|
||||||
use unlucky_design::icons::IconKind;
|
use unlucky_design::icons::IconKind;
|
||||||
|
|
||||||
@@ -14,9 +30,11 @@ pub(super) const GROUP: Group = Group {
|
|||||||
name: "Downloads",
|
name: "Downloads",
|
||||||
icon: IconKind::Download,
|
icon: IconKind::Download,
|
||||||
rows: &[
|
rows: &[
|
||||||
("Preferred source", "Automatic (region)"),
|
("并发数", "出厂 8 · 可调 1~64"),
|
||||||
("Concurrency", "8 threads"),
|
("单源尝试次数", "出厂 3 · 含首次 · 0/1 = 只试一次"),
|
||||||
("Verify checksums", "On"),
|
("GitHub 加速前缀", "留空 = 跟随内置默认"),
|
||||||
|
("关掉它", "填 off = 关闭"),
|
||||||
|
("换掉它", "填 URL 前缀 = 覆盖"),
|
||||||
],
|
],
|
||||||
body: None,
|
body: None,
|
||||||
};
|
};
|
||||||
@@ -24,6 +42,7 @@ pub(super) const GROUP: Group = Group {
|
|||||||
/// Downloads 分组的运行期状态(M101 r2:**只归 download 线**)。
|
/// Downloads 分组的运行期状态(M101 r2:**只归 download 线**)。
|
||||||
///
|
///
|
||||||
/// 加字段只改本文件:`Groups` 只认 `Groups.download` 这一个字段。
|
/// 加字段只改本文件:`Groups` 只认 `Groups.download` 这一个字段。
|
||||||
|
/// 现在仍然是空的——真值的数据通路还没接(见文件头「为什么这三行是说语义」)。
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct State {}
|
pub struct State {}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//! 一条 section 断言都没有;这 3 条是新增,不是搬移)。
|
//! 一条 section 断言都没有;这 3 条是新增,不是搬移)。
|
||||||
//!
|
//!
|
||||||
//! **归 download 线**。接真控件那天要改**两个**文件(M101 r2 删掉了原先的第 3 处跨 section 绊线):
|
//! **归 download 线**。接真控件那天要改**两个**文件(M101 r2 删掉了原先的第 3 处跨 section 绊线):
|
||||||
//! `settings/download.rs` + 本文件 `the_downloads_group_is_still_demo_rows`。
|
//! `settings/download.rs` + 本文件。
|
||||||
//!
|
//!
|
||||||
//! 别把它和下载页(`pages/download*`)搞混:那是「装哪个版本」,这一组是「怎么下」的默认值。
|
//! 别把它和下载页(`pages/download*`)搞混:那是「装哪个版本」,这一组是「怎么下」的默认值。
|
||||||
|
|
||||||
@@ -11,18 +11,20 @@ use unlucky_design::icons::IconKind;
|
|||||||
|
|
||||||
/// 这一组的行集合(标签 + 值)。
|
/// 这一组的行集合(标签 + 值)。
|
||||||
///
|
///
|
||||||
/// 抓得住:① 四条线并行合并时把别人的行粘到本文件(四份 `GROUP` 长得几乎一样,最容易整块贴错);
|
/// 抓得住:① 四条线并行合并时把别人的行粘到本文件(六份 `GROUP` 长得几乎一样,最容易整块贴错);
|
||||||
/// ② 漏掉/多出一条行;③ 标签与值错位(`("Concurrency", "8 threads")` 被写成 `"Off"`)。
|
/// ② 漏掉/多出一条行;③ 标签与值错位。
|
||||||
/// 抓不住:值对不对(`Automatic (region)` 是不是本机真选中的源)——那是接线后真数据源的事。
|
/// 抓不住:值对不对——这三行**本来就不是值**(M193 起改成语义行,见下一条用例)。
|
||||||
#[test]
|
#[test]
|
||||||
fn the_downloads_group_shows_source_concurrency_and_checksums() {
|
fn the_downloads_group_shows_concurrency_retries_and_the_gh_proxy_key() {
|
||||||
let rows: Vec<(&str, &str)> = download::GROUP.rows.to_vec();
|
let rows: Vec<(&str, &str)> = download::GROUP.rows.to_vec();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
rows,
|
rows,
|
||||||
vec![
|
vec![
|
||||||
("Preferred source", "Automatic (region)"),
|
("并发数", "出厂 8 · 可调 1~64"),
|
||||||
("Concurrency", "8 threads"),
|
("单源尝试次数", "出厂 3 · 含首次 · 0/1 = 只试一次"),
|
||||||
("Verify checksums", "On"),
|
("GitHub 加速前缀", "留空 = 跟随内置默认"),
|
||||||
|
("关掉它", "填 off = 关闭"),
|
||||||
|
("换掉它", "填 URL 前缀 = 覆盖"),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -33,17 +35,61 @@ fn the_downloads_group_keeps_its_icon() {
|
|||||||
assert_eq!(download::GROUP.icon, IconKind::Download);
|
assert_eq!(download::GROUP.icon, IconKind::Download);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **绊线**:这一组还没接真控件,画的是演示行。
|
/// **M193 换了绊线的内容,但没撤绊线**。
|
||||||
///
|
///
|
||||||
/// 抓得住:download 线接上了 `body` 却没更新这里的期望(那会让本文件静默变成假绿)。
|
/// 原绊线是 `the_downloads_group_is_still_demo_rows`:断言 `body.is_none()`(这一组还没接真控件)。
|
||||||
/// 抓不住:界面上真控件长什么样。
|
/// 它存在的意义是「防止演示值回潮」。M193 撤掉的只是**演示值**(`("Concurrency", "8 threads")`
|
||||||
|
/// 这类):那个 8 与引擎真实的 64 对不上,用户按它调设置自然没效果。
|
||||||
///
|
///
|
||||||
/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**(M101 r2 起不必再去
|
/// 现在改成两条更硬的断言:
|
||||||
/// `tests/mod.rs` 报到:那条跨 section 聚合绊线已删)。
|
///
|
||||||
|
/// 1. **仍然没有真控件**(`body.is_none()`)——真值的通路还没接(`State` 是空 struct、
|
||||||
|
/// iced 侧没有设置快照),所以这一组还是静态行;接上那天把这条改成 `is_some()` 并接数据。
|
||||||
|
/// 2. **行里不许再出现「当前值」形态的字面量**:三行必须把「出厂值 / 区间 / 三态」讲清楚,
|
||||||
|
/// 且 gh-proxy 那一行必须**同时**说清「留空 = 跟随内置默认」与「填 off = 关闭」——
|
||||||
|
/// 这两者最容易被混(一个「我没决定」,一个是「我明确不要」)。
|
||||||
|
///
|
||||||
|
/// 变异证明:把 `download.rs` 的 gh_proxy 行改回 `("Preferred source", "Automatic (region)")`
|
||||||
|
/// ⇒ 第 2 条红;把 `enabled` 语义写成「留空 = 关闭」⇒ 第 2 条红。
|
||||||
#[test]
|
#[test]
|
||||||
fn the_downloads_group_is_still_demo_rows() {
|
fn the_downloads_rows_state_semantics_and_never_fake_a_current_value() {
|
||||||
assert!(
|
assert!(
|
||||||
download::GROUP.body.is_none(),
|
download::GROUP.body.is_none(),
|
||||||
"Downloads 还没接真控件:接了就改本断言"
|
"Downloads 还没接真控件:接了就改成 is_some() 并接数据通路"
|
||||||
|
);
|
||||||
|
|
||||||
|
let joined = download::GROUP
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.map(|(label, value)| format!("{label} {value}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" | ");
|
||||||
|
|
||||||
|
for required in [
|
||||||
|
// 三态里最容易被混的两档,必须都在(缺一个就是「设置项说假话」的种子)。
|
||||||
|
"留空 = 跟随内置默认",
|
||||||
|
"填 off = 关闭",
|
||||||
|
"填 URL 前缀 = 覆盖",
|
||||||
|
// 硬上限必须在界面上说清(任务书的边界条款)。
|
||||||
|
"1~64",
|
||||||
|
// P2-1:0 是旧版提供过的档位,界面必须说清它 = 只试一次(不是「不下载」)。
|
||||||
|
"0/1 = 只试一次",
|
||||||
|
// 三个键各自是哪一行(标签本身也是契约:用户按它认设置)。
|
||||||
|
"并发数",
|
||||||
|
"单源尝试次数",
|
||||||
|
"GitHub 加速前缀",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
joined.contains(required),
|
||||||
|
"行里缺少「{required}」:{joined}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 「当前值」形态的假数据:`8 threads` / `On` / `Automatic (region)` 都是当年被投诉的那类。
|
||||||
|
for forbidden in ["threads", "Automatic (region)", "Verify checksums"] {
|
||||||
|
assert!(
|
||||||
|
!joined.contains(forbidden),
|
||||||
|
"又出现假的当前值「{forbidden}」:{joined}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,51 @@
|
|||||||
//! 引擎配置:设置层(core::setting)直接映射到这里。
|
//! 引擎配置:设置层(core::setting)直接映射到这里。
|
||||||
|
//!
|
||||||
|
//! # 出厂默认值与设置 schema 的唯一来源(M193)
|
||||||
|
//!
|
||||||
|
//! 四个常量是**唯一**写死数字的地方:schema 的默认值/范围、`EngineConfig::default()`、
|
||||||
|
//! `Semaphore` 容量全从它们取。此前 schema 写 8、引擎写 64 ⇒ 调并发完全无效(引擎侧零消费者)。
|
||||||
|
//! 收成常量后「改一处另一个不变」会直接变红(守卫见 `shared.rs` 与本文件的用例)。
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
use std::time::Duration;
|
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::types::ResumePolicy;
|
use super::types::ResumePolicy;
|
||||||
|
|
||||||
|
/// 全局并发许可数的出厂默认(`download.concurrency` 的 schema 默认值同源)。
|
||||||
|
pub const DEFAULT_MAX_CONCURRENCY: usize = 8;
|
||||||
|
|
||||||
|
/// 全局并发许可的**硬上限**(`download.concurrency` 的 schema 上界同源)。schema 在写入口就拒绝
|
||||||
|
/// 越界值,这里再夹一次是纵深防御(手改文件、老 schema 允许的大数)——9999 会把机器拖死。
|
||||||
|
pub const MAX_CONCURRENCY: usize = 64;
|
||||||
|
|
||||||
|
/// 每个源尝试次数的出厂默认(`download.retry_limit` 的 schema 默认值同源)。
|
||||||
|
pub const DEFAULT_MAX_ATTEMPTS_PER_SOURCE: u32 = 3;
|
||||||
|
|
||||||
|
/// 每个源尝试次数的硬上限(`download.retry_limit` 的 schema 上界同源)。
|
||||||
|
pub const MAX_ATTEMPTS_PER_SOURCE: u32 = 10;
|
||||||
|
|
||||||
|
/// 三个设置键名(层间契约):登记在 `setting::keys`,读取在宿主。只写在这里,两侧都引用常量。
|
||||||
|
pub const KEY_CONCURRENCY: &str = "download.concurrency";
|
||||||
|
/// 见 [`KEY_CONCURRENCY`]。
|
||||||
|
pub const KEY_RETRY_LIMIT: &str = "download.retry_limit";
|
||||||
|
/// gh-proxy 前缀覆盖键(三态语义见 [`gh_proxy_override`])。
|
||||||
|
///
|
||||||
|
/// **暂未登记**:由 M197 在 `setting::keys::proxy` 登记,尚未合入 dev;宿主读它得到 `None` ⇒
|
||||||
|
/// [`GhProxyOverride::Builtin`](行为正确)。**M197 合入后这条断言应升级为 schema 断言**——现在
|
||||||
|
/// 只断言常量本身,别把这条弱断言当成可以随手删的。
|
||||||
|
pub const KEY_GH_PROXY: &str = "download.gh_proxy";
|
||||||
|
|
||||||
/// 引擎配置。
|
/// 引擎配置。
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct EngineConfig {
|
pub struct EngineConfig {
|
||||||
/// 全局并发许可数(tokio `Semaphore`),默认 64。
|
/// 全局并发许可数(tokio `Semaphore`),默认 [`DEFAULT_MAX_CONCURRENCY`]。
|
||||||
pub max_concurrency: usize,
|
pub max_concurrency: usize,
|
||||||
/// 每个源的尝试次数上限,默认 3。
|
/// 每个源的尝试次数上限,默认 [`DEFAULT_MAX_ATTEMPTS_PER_SOURCE`]。
|
||||||
pub max_attempts_per_source: u32,
|
pub max_attempts_per_source: u32,
|
||||||
/// 连接超时,默认 10s。
|
/// 连接超时,默认 10s。
|
||||||
pub connect_timeout: Duration,
|
pub connect_timeout: Duration,
|
||||||
@@ -32,8 +66,8 @@ pub struct EngineConfig {
|
|||||||
impl Default for EngineConfig {
|
impl Default for EngineConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
max_concurrency: 64,
|
max_concurrency: DEFAULT_MAX_CONCURRENCY,
|
||||||
max_attempts_per_source: 3,
|
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),
|
||||||
user_agent: concat!("UnluckyLauncher/", env!("CARGO_PKG_VERSION")).to_string(),
|
user_agent: concat!("UnluckyLauncher/", env!("CARGO_PKG_VERSION")).to_string(),
|
||||||
@@ -44,3 +78,323 @@ impl Default for EngineConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `download.gh_proxy` 的三种取值(三态语义见 [`gh_proxy_override`])。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub enum GhProxyOverride {
|
||||||
|
/// 空串:**没有覆盖**,跟随内置默认(地区判定 + 镜像表里的前缀)。
|
||||||
|
#[default]
|
||||||
|
Builtin,
|
||||||
|
/// 明确关掉:一个 gh-proxy 候选都不生成(`RegionProfile::with_gh_proxy(None)`)。
|
||||||
|
Off,
|
||||||
|
/// 用这个前缀覆盖(仍然只在镜像优先 / 国内判定时启用)。
|
||||||
|
Prefix(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置原文 → gh-proxy 三态(纯函数:`off` 与 URL 的边界在这里定死,不散落到宿主)。
|
||||||
|
///
|
||||||
|
/// **空串不等于关**:「我不想用加速」和「我还没决定」是两件事。空串 = 跟随内置默认,
|
||||||
|
/// 要关必须写 `off`(`none` / `direct` / `disable` / `disabled` / `no` 这些口语词一并认,
|
||||||
|
/// 大小写不敏感、前后空白忽略——用户拿哪个词表达「关掉」都合理,专门要求某一个会造出
|
||||||
|
/// 「填了没生效」的坑)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn gh_proxy_override(raw: &str) -> GhProxyOverride {
|
||||||
|
let text = raw.trim();
|
||||||
|
if text.is_empty() {
|
||||||
|
return GhProxyOverride::Builtin;
|
||||||
|
}
|
||||||
|
let lowered = text.to_ascii_lowercase();
|
||||||
|
if matches!(
|
||||||
|
lowered.as_str(),
|
||||||
|
"off" | "none" | "direct" | "disable" | "disabled" | "no"
|
||||||
|
) {
|
||||||
|
return GhProxyOverride::Off;
|
||||||
|
}
|
||||||
|
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>> {
|
||||||
|
static WANTED: OnceLock<Mutex<HashMap<PathBuf, DownloadTuning>>> = OnceLock::new();
|
||||||
|
WANTED.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 该数据根当前想要的调优(没灌过 = 出厂默认)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn wanted(cache_root: &Path) -> DownloadTuning {
|
||||||
|
wanted_slot()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
.get(cache_root)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记下该数据根想要的调优,返回**是否真的变了**(没变时调用方不该重建引擎)。
|
||||||
|
pub fn publish(cache_root: &Path, next: DownloadTuning) -> bool {
|
||||||
|
let mut wanted = wanted_slot()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
// 归一化比较:「没灌过」与「灌了出厂默认」是同一件事,不能因为 `None != Some(default)`
|
||||||
|
// 就把每次取引擎都当成「参数变了」(那会每次重建引擎、把连接池与健康度记忆全丢掉)。
|
||||||
|
let current = wanted.get(cache_root).cloned().unwrap_or_default();
|
||||||
|
if current == next {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if next == DownloadTuning::default() {
|
||||||
|
// 默认值不占格子(也顺手回收测试造的临时根)。
|
||||||
|
wanted.remove(cache_root);
|
||||||
|
} else {
|
||||||
|
wanted.insert(cache_root.to_path_buf(), next);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::setting::keys::{launcher_schema, launcher_version};
|
||||||
|
|
||||||
|
/// **守门③**:schema 的默认值 / 区间 / 键名与 `EngineConfig::default()` / 调优默认值取同一组常量。
|
||||||
|
/// 变异证明(只改实现侧):`EngineConfig::default()` 的并发写回 64、schema 默认值写成 16、
|
||||||
|
/// `MAX_CONCURRENCY` 改了而 schema 仍写 64 ⇒ 各自红。
|
||||||
|
#[test]
|
||||||
|
fn the_schema_contract_is_single_sourced() {
|
||||||
|
let schema = launcher_schema();
|
||||||
|
assert_eq!(schema.version(), launcher_version());
|
||||||
|
let cfg = EngineConfig::default();
|
||||||
|
let tune = DownloadTuning::default();
|
||||||
|
|
||||||
|
// 并发 1~64(0 张许可没意义);尝试次数 **0**~10(P2-1:0 = 只试一次,旧配置里真有)。
|
||||||
|
let contract: [(&str, i64, i64, usize); 2] = [
|
||||||
|
(
|
||||||
|
KEY_CONCURRENCY,
|
||||||
|
i64::try_from(DEFAULT_MAX_CONCURRENCY).expect("装得下"),
|
||||||
|
1,
|
||||||
|
MAX_CONCURRENCY,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
KEY_RETRY_LIMIT,
|
||||||
|
i64::from(DEFAULT_MAX_ATTEMPTS_PER_SOURCE),
|
||||||
|
0,
|
||||||
|
usize::try_from(MAX_ATTEMPTS_PER_SOURCE).expect("装得下"),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (key, default, min, max) 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: i64::try_from(max).expect("装得下"),
|
||||||
|
},
|
||||||
|
"{key}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(cfg.max_concurrency, DEFAULT_MAX_CONCURRENCY);
|
||||||
|
assert_eq!(tune.max_concurrency, DEFAULT_MAX_CONCURRENCY);
|
||||||
|
assert_eq!(cfg.max_attempts_per_source, DEFAULT_MAX_ATTEMPTS_PER_SOURCE);
|
||||||
|
assert_eq!(
|
||||||
|
tune.max_attempts_per_source,
|
||||||
|
DEFAULT_MAX_ATTEMPTS_PER_SOURCE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `SettingSpec.doc` 是 `&'static str`(`concat!` 插不进常量),描述里的数字只能手写——这条钉住它,
|
||||||
|
/// 改常量不改文案就红(`catalog.rs` 会把这些 doc 当 JSON schema 的 description 发出去)。
|
||||||
|
#[test]
|
||||||
|
fn the_key_docs_quote_the_real_limits() {
|
||||||
|
let schema = launcher_schema();
|
||||||
|
let doc = |key: &str| schema.spec(key).expect("键已登记").doc;
|
||||||
|
assert!(doc(KEY_CONCURRENCY).contains(&format!("1~{MAX_CONCURRENCY}")));
|
||||||
|
assert!(doc(KEY_CONCURRENCY).contains(&format!("出厂 {DEFAULT_MAX_CONCURRENCY}")));
|
||||||
|
assert!(doc(KEY_RETRY_LIMIT).contains(&format!("出厂 {DEFAULT_MAX_ATTEMPTS_PER_SOURCE}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 未填 / 越界回落默认(不阻断下载);**`retry_limit: 0` 是合法值**,语义 = 只试一次。
|
||||||
|
///
|
||||||
|
/// 变异证明:把并发的区间过滤删掉 ⇒ `0` / `9999` 两条红;把尝试次数的 `.max(1)` 删掉 ⇒
|
||||||
|
/// `0 → 1` 那条红(引擎会拿到 0 次尝试);把 `0` 判成越界 ⇒ 同样红。
|
||||||
|
#[test]
|
||||||
|
fn missing_or_illegal_values_fall_back_to_the_defaults() {
|
||||||
|
assert_eq!(
|
||||||
|
DownloadTuning::from_settings(None, None, None),
|
||||||
|
DownloadTuning::default()
|
||||||
|
);
|
||||||
|
|
||||||
|
let just_over = i64::try_from(MAX_CONCURRENCY).expect("上限装得下") + 1;
|
||||||
|
for bad in [0_i64, -1, 9_999, just_over] {
|
||||||
|
let tune = DownloadTuning::from_settings(Some(bad), None, None);
|
||||||
|
assert_eq!(tune.max_concurrency, DEFAULT_MAX_CONCURRENCY, "{bad}");
|
||||||
|
}
|
||||||
|
// 0 与 1 都 = 只试一次:旧配置里的 0 不许回落成出厂 3(那正是「界面 0、引擎 3」)。
|
||||||
|
for zero_or_one in [0_i64, 1] {
|
||||||
|
let tune = DownloadTuning::from_settings(None, Some(zero_or_one), None);
|
||||||
|
assert_eq!(tune.max_attempts_per_source, 1, "{zero_or_one}");
|
||||||
|
}
|
||||||
|
for bad in [-1_i64, 9_999] {
|
||||||
|
let tune = DownloadTuning::from_settings(None, Some(bad), None);
|
||||||
|
assert_eq!(
|
||||||
|
tune.max_attempts_per_source,
|
||||||
|
DEFAULT_MAX_ATTEMPTS_PER_SOURCE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tune = DownloadTuning::from_settings(Some(2), Some(2), None);
|
||||||
|
assert_eq!(tune.max_concurrency, 2);
|
||||||
|
assert_eq!(tune.max_attempts_per_source, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 三态:**空串 ≠ 关**(这条是「设置项说假话」的高发点,M197 的键语义同源)。
|
||||||
|
#[test]
|
||||||
|
fn empty_gh_proxy_means_follow_builtin_not_off() {
|
||||||
|
assert_eq!(gh_proxy_override(""), GhProxyOverride::Builtin);
|
||||||
|
assert_eq!(gh_proxy_override(" "), GhProxyOverride::Builtin);
|
||||||
|
for off in [
|
||||||
|
"off", "OFF", " none ", "direct", "disable", "disabled", "no",
|
||||||
|
] {
|
||||||
|
assert_eq!(gh_proxy_override(off), GhProxyOverride::Off, "{off}");
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
gh_proxy_override(" https://gh.example/ "),
|
||||||
|
GhProxyOverride::Prefix("https://gh.example/".to_string())
|
||||||
|
);
|
||||||
|
// 未填(键不存在)也是 Builtin,不是 Off。
|
||||||
|
assert_eq!(
|
||||||
|
DownloadTuning::from_settings(None, None, None).gh_proxy,
|
||||||
|
GhProxyOverride::Builtin
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `apply_gh_proxy` 的三档真的改到档案上、并且**改到候选链上**(`expand()` 读的就是档案里那个
|
||||||
|
/// 前缀;自定义前缀那一档的链断言在 `shared.rs`)。变异证明:`Off` 分支改成原样返回 ⇒ `off` 红。
|
||||||
|
#[test]
|
||||||
|
fn gh_proxy_override_reaches_the_profile_and_the_chain() {
|
||||||
|
use crate::download::mirrors::{MirrorTable, SOURCE_GH_PROXY};
|
||||||
|
use crate::download::types::ResourceKind;
|
||||||
|
|
||||||
|
let url = "https://github.com/o/r/releases/download/v1/a.jar";
|
||||||
|
let table = MirrorTable::builtin();
|
||||||
|
let sources = |profile: &RegionProfile| -> Vec<String> {
|
||||||
|
table
|
||||||
|
.expand(url, ResourceKind::Other, profile)
|
||||||
|
.into_iter()
|
||||||
|
.map(|candidate| candidate.source)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mirror_first = RegionProfile::mirror_first(0);
|
||||||
|
let kept = DownloadTuning::default().apply_gh_proxy(mirror_first.clone());
|
||||||
|
assert_eq!(kept.gh_proxy, mirror_first.gh_proxy, "Builtin 不该动档案");
|
||||||
|
assert!(
|
||||||
|
sources(&kept).contains(&SOURCE_GH_PROXY.to_string()),
|
||||||
|
"出厂套前缀"
|
||||||
|
);
|
||||||
|
|
||||||
|
let off = DownloadTuning::from_settings(None, None, Some("off"))
|
||||||
|
.apply_gh_proxy(mirror_first.clone());
|
||||||
|
assert!(
|
||||||
|
!sources(&off).contains(&SOURCE_GH_PROXY.to_string()),
|
||||||
|
"关了就没候选"
|
||||||
|
);
|
||||||
|
|
||||||
|
let prefix = DownloadTuning::from_settings(None, None, Some("https://gh.example/"))
|
||||||
|
.apply_gh_proxy(mirror_first);
|
||||||
|
assert_eq!(prefix.gh_proxy.as_deref(), Some("https://gh.example/"));
|
||||||
|
assert!(prefix.gh_proxy_enabled(), "前缀生效");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 每个数据根的调优互不串味(一个根改成 2 不该改另一个根的引擎)。
|
||||||
|
/// 变异证明:把 `wanted`/`publish` 的键改回单一进程级槽位 ⇒ 第二个根读到 2,红。
|
||||||
|
#[test]
|
||||||
|
fn tuning_is_per_cache_root_and_no_change_is_reported_as_such() {
|
||||||
|
let a = PathBuf::from("/tmp/ull-tuning-a");
|
||||||
|
let b = PathBuf::from("/tmp/ull-tuning-b");
|
||||||
|
assert_eq!(wanted(&a), DownloadTuning::default());
|
||||||
|
|
||||||
|
let changed = DownloadTuning::from_settings(Some(2), None, None);
|
||||||
|
assert!(publish(&a, changed.clone()), "真变了要报变更");
|
||||||
|
assert_eq!(wanted(&a), changed);
|
||||||
|
assert!(!publish(&a, changed.clone()), "同值不该报变更");
|
||||||
|
assert_eq!(wanted(&b), DownloadTuning::default(), "别的根不受影响");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
publish(&a, DownloadTuning::default()),
|
||||||
|
"恢复默认也是一次变更"
|
||||||
|
);
|
||||||
|
assert_eq!(wanted(&a), DownloadTuning::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,18 +5,15 @@
|
|||||||
//! ```text
|
//! ```text
|
||||||
//! 校验请求 → 内容寻址缓存直取(零网络)
|
//! 校验请求 → 内容寻址缓存直取(零网络)
|
||||||
//! → 候选规划(镜像表展开 + 健康度排序)
|
//! → 候选规划(镜像表展开 + 健康度排序)
|
||||||
//! → Semaphore 限流
|
//! → Semaphore 限流 ← 容量来自设置(M193,见 config.rs)
|
||||||
//! → 候选源外循环(换源),每源内循环(指数退避 + 抖动)
|
//! → 候选源外循环(换源),每源内循环(指数退避 + 抖动)
|
||||||
//! → 续传判定(保守)→ 单连接 / 多连接分块 → 流式校验 → 原子落盘
|
//! → 续传判定(保守)→ 单连接 / 多连接分块 → 流式校验 → 原子落盘
|
||||||
//! → 失败则保留中间文件供下次续传
|
//! → 失败则保留中间文件供下次续传
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! 最后一步保留的是**本会话命名的**中间文件(`<name>.ullpart-<session>-<seq>`,不再固定
|
//! 中间文件是本会话命名的(`<name>.ullpart-<session>-<seq>`),下次(甚至下次启动)由
|
||||||
//! 叫 `<name>.ullpart`):下次(甚至下次启动、重启后再下同一个文件)由 `PartFile::for_dest`
|
//! `PartFile::for_dest` 认领并改名,续传判定照旧(身份规则见 `atomic.rs` 模块头)。传输细节在
|
||||||
//! 在目标目录里认领它并改名成新的会话名,续传判定照旧。详细身份规则见 `atomic.rs` 模块头的
|
//! `transfer.rs`,错误分类在 `http.rs`,本文件只负责顺序与策略。
|
||||||
//! 「中间文件的身份」。
|
|
||||||
//!
|
|
||||||
//! 具体传输细节在 `transfer.rs`,错误分类在 `http.rs`,本文件只负责顺序与策略。
|
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -27,7 +24,7 @@ use tokio::sync::Semaphore;
|
|||||||
use super::atomic::PartFile;
|
use super::atomic::PartFile;
|
||||||
use super::backoff::Jitter;
|
use super::backoff::Jitter;
|
||||||
use super::cache::{now_ms, ContentCache};
|
use super::cache::{now_ms, ContentCache};
|
||||||
use super::config::EngineConfig;
|
use super::config::{EngineConfig, MAX_CONCURRENCY};
|
||||||
use super::error::{DownloadError, DownloadErrorKind};
|
use super::error::{DownloadError, DownloadErrorKind};
|
||||||
use super::health::HealthRegistry;
|
use super::health::HealthRegistry;
|
||||||
use super::http;
|
use super::http;
|
||||||
@@ -72,8 +69,10 @@ impl DownloadEngine {
|
|||||||
cache: Arc<ContentCache>,
|
cache: Arc<ContentCache>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let http = http::build_client(&cfg).unwrap_or_else(|_| reqwest::Client::new());
|
let http = http::build_client(&cfg).unwrap_or_else(|_| reqwest::Client::new());
|
||||||
let sem = Arc::new(Semaphore::new(cfg.max_concurrency.max(1)));
|
// 硬上限夹取(M193):手改的 launcher.json / CLI 自拼的 cfg 都可能写着 9999,在唯一构造点再夹一次。
|
||||||
let seed = now_ms() ^ (u64::from(cfg.max_concurrency as u32) << 32);
|
let permits = cfg.max_concurrency.clamp(1, MAX_CONCURRENCY);
|
||||||
|
let sem = Arc::new(Semaphore::new(permits));
|
||||||
|
let seed = now_ms() ^ (u64::from(u32::try_from(permits).unwrap_or(u32::MAX)) << 32);
|
||||||
// 纯记账(M165):卡在构造这个点上,才统计得到绕过登记处的调用点(守卫靠 `constructions`)。
|
// 纯记账(M165):卡在构造这个点上,才统计得到绕过登记处的调用点(守卫靠 `constructions`)。
|
||||||
super::shared::record_build(cache.root());
|
super::shared::record_build(cache.root());
|
||||||
Self {
|
Self {
|
||||||
@@ -102,10 +101,15 @@ impl DownloadEngine {
|
|||||||
&self.cfg
|
&self.cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
/// HTTP 客户端(同一条连接池与超时配置)。
|
/// **真实并发闸门**此刻的许可数(探针,M193):读 [`Self::acquire`] 会去抢的那张
|
||||||
///
|
/// `Semaphore`,**不是** `config().max_concurrency`。有下载在飞时偏小,空闲等于容量。
|
||||||
/// 给「一锤子查询」用——例如 Adoptium 的版本查询(`core::game::java::api`):
|
#[must_use]
|
||||||
/// 没必要为了一个 GET 再造一个 client,代理/超时/证书栈都在这里统一。
|
pub fn concurrency_probe(&self) -> usize {
|
||||||
|
self.sem.available_permits()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP 客户端(同一条连接池与超时配置):给「一锤子查询」用(如 Adoptium 版本查询),
|
||||||
|
/// 没必要为了一个 GET 再造一个 client。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn client(&self) -> &reqwest::Client {
|
pub fn client(&self) -> &reqwest::Client {
|
||||||
&self.http
|
&self.http
|
||||||
@@ -243,7 +247,7 @@ impl DownloadEngine {
|
|||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
// 4xx(NotFound)不算「源坏」:院校镜像只留最新补丁,请求旧补丁必然 404,
|
// 4xx(NotFound)不算「源坏」:院校镜像只留最新补丁,请求旧补丁必然 404,
|
||||||
// 记成失败会把唯一可用镜像冷却掉(M33/M34 实测:TUNA 每个目录只保 1 个归档)。
|
// 记成失败会把唯一可用镜像冷却掉(M33/M34 实测:TUNA 每目录只保 1 个归档)。
|
||||||
if err.kind == DownloadErrorKind::NotFound {
|
if err.kind == DownloadErrorKind::NotFound {
|
||||||
self.health.record_missing(
|
self.health.record_missing(
|
||||||
&candidate.source,
|
&candidate.source,
|
||||||
@@ -346,11 +350,8 @@ impl DownloadEngine {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 该源此刻给几次尝试:
|
/// 该源此刻给几次尝试:冷却中 / 刚出冷却未洗白 → 1 次(那就是恢复探测,别让死源每次到期
|
||||||
///
|
/// 都吃满预算);正常 → 设置里的 `download.retry_limit`(= `max_attempts_per_source`)。
|
||||||
/// * 冷却中 → 1 次(这次就是「恢复探测」,失败会重新冷却);
|
|
||||||
/// * 刚出冷却、还没洗白 → 也只给 1 次(`is_probe()`,别让死源每次到期都吃满 3 次);
|
|
||||||
/// * 正常 → 配置的 `max_attempts_per_source`。
|
|
||||||
pub(crate) fn attempts_for(&self, source: &str, kind: ResourceKind, now_ms: u64) -> u32 {
|
pub(crate) fn attempts_for(&self, source: &str, kind: ResourceKind, now_ms: u64) -> u32 {
|
||||||
let health = self.health.health_of(source, kind);
|
let health = self.health.health_of(source, kind);
|
||||||
if health.is_cooling_down(now_ms) || health.is_probe(now_ms) {
|
if health.is_cooling_down(now_ms) || health.is_probe(now_ms) {
|
||||||
|
|||||||
@@ -130,7 +130,8 @@ pub struct MirrorTable {
|
|||||||
/// Zulu 元数据(v3 新增)。
|
/// Zulu 元数据(v3 新增)。
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub zulu: Option<ZuluTable>,
|
pub zulu: Option<ZuluTable>,
|
||||||
/// gh-proxy 类前缀。
|
/// gh-proxy 类前缀(**目前无生产读者**:生效前缀的唯一真相源是 `RegionProfile.gh_proxy`;
|
||||||
|
/// 这一列只作为 `mirrors.json` 的解析留档,别再按它做「改这里能生效」的接口)。
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub gh_proxies: Vec<GhProxy>,
|
pub gh_proxies: Vec<GhProxy>,
|
||||||
}
|
}
|
||||||
@@ -175,49 +176,18 @@ impl MirrorTable {
|
|||||||
self.rules.insert(0, rule);
|
self.rules.insert(0, rule);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 覆盖 gh-proxy 前缀(设置项);传 `None` 表示不默认启用。
|
|
||||||
pub fn set_gh_proxy(&mut self, prefix: Option<&str>) {
|
|
||||||
match prefix {
|
|
||||||
Some(prefix) => {
|
|
||||||
if let Some(proxy) = self.gh_proxies.first_mut() {
|
|
||||||
proxy.prefix = prefix.to_string();
|
|
||||||
proxy.enabled = true;
|
|
||||||
} else {
|
|
||||||
self.gh_proxies.push(GhProxy {
|
|
||||||
id: "gh-proxy".to_string(),
|
|
||||||
name: "自定义 gh-proxy".to_string(),
|
|
||||||
prefix: prefix.to_string(),
|
|
||||||
enabled: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
for proxy in &mut self.gh_proxies {
|
|
||||||
proxy.enabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 找到第一条命中的前缀规则。
|
/// 找到第一条命中的前缀规则。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn prefix_rule_for(&self, url: &str, kind: ResourceKind) -> Option<&PrefixRule> {
|
pub fn prefix_rule_for(&self, url: &str, kind: ResourceKind) -> Option<&PrefixRule> {
|
||||||
self.rules.iter().find(|rule| rule.matches(url, kind))
|
self.rules.iter().find(|rule| rule.matches(url, kind))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 清单里第一个启用的 gh-proxy 前缀(默认值来源;设置层用它初始化地区档案)。
|
|
||||||
#[must_use]
|
|
||||||
pub fn default_gh_proxy(&self) -> Option<&str> {
|
|
||||||
self.gh_proxies
|
|
||||||
.iter()
|
|
||||||
.find(|proxy| proxy.enabled && !proxy.prefix.is_empty())
|
|
||||||
.map(|proxy| proxy.prefix.as_str())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GitHub host 且允许时套 gh-proxy 前缀。
|
/// GitHub host 且允许时套 gh-proxy 前缀。
|
||||||
///
|
///
|
||||||
/// **生效前缀的唯一真相源是 `region.gh_proxy`**(设置层/地区判定写入,`None` = 用户关闭)。
|
/// **生效前缀的唯一真相源是 `region.gh_proxy`**(设置层/地区判定写入,`None` = 用户关闭)。
|
||||||
/// 表格里的 `gh_proxies` 只是「已知可用前缀」清单,配合 `default_gh_proxy()` 给设置做默认值。
|
/// 表格里的 `gh_proxies` 只是「已知可用前缀」的留档(无生产读者),**改它不会生效**——M193
|
||||||
|
/// 删掉了原先那个 `MirrorTable::set_gh_proxy`,正是因为它改了这张表而 `expand()` 读的是档案,
|
||||||
|
/// 属于「看起来能改、其实没人读」的入口。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn gh_proxy_candidates(&self, url: &str, region: &RegionProfile) -> Vec<Candidate> {
|
pub fn gh_proxy_candidates(&self, url: &str, region: &RegionProfile) -> Vec<Candidate> {
|
||||||
if region.mode != RegionMode::MirrorFirst {
|
if region.mode != RegionMode::MirrorFirst {
|
||||||
|
|||||||
@@ -32,23 +32,23 @@
|
|||||||
//! 只 forget 的话它会一直用旧实例(含旧地区档案)直到进程结束。所以「重新检测地区」那条
|
//! 只 forget 的话它会一直用旧实例(含旧地区档案)直到进程结束。所以「重新检测地区」那条
|
||||||
//! 设置动作除了 [`forget`] 还调 `CoreHost::refresh_update_factory()` 把新引擎接过去
|
//! 设置动作除了 [`forget`] 还调 `CoreHost::refresh_update_factory()` 把新引擎接过去
|
||||||
//! (M165 复审 P2-1:这是本模块文档原先说漏的一半)。
|
//! (M165 复审 P2-1:这是本模块文档原先说漏的一半)。
|
||||||
//! * **并发多少**:`EngineConfig::default().max_concurrency`(64)现在是**进程级**上限,
|
//! * **并发多少**(M193):容量来自设置 `download.concurrency`(出厂 8、硬上限 64,见 [`set_tuning`]),
|
||||||
//! 不再是「每请求 64」。单请求独占时与从前一致;多请求并发时它们分摊这 64 张许可。
|
//! 而且是**进程级**上限——多请求并发时分摊这批许可,不是「每请求 8」。
|
||||||
//! * **镜像切换 / 失效**:一律走引擎原有的候选规划 + 健康度冷却,本模块不参与判定;唯一可见的
|
//! * **镜像切换 / 失效**:一律走引擎原有的候选规划 + 健康度冷却,本模块不参与判定;唯一可见的
|
||||||
//! 差别是健康度记忆现在真的跨请求累积(这本就是它存在的理由)。
|
//! 差别是健康度记忆现在真的跨请求累积(这本就是它存在的理由)。
|
||||||
//!
|
//!
|
||||||
//! # 与 [`super::blocking::block_on`] 的绑定关系
|
//! # 与 [`super::blocking::block_on`] 的绑定关系
|
||||||
//!
|
//!
|
||||||
//! `reqwest` 的连接池绑在**驱动连接的运行时**上:引擎复用了而每次 `block_on` 都新起一个运行时,
|
//! `reqwest` 的连接池绑在**驱动连接的运行时**上:引擎复用了而每次 `block_on` 都新起一个运行时,
|
||||||
//! 池里的空闲连接就属于已经销毁的运行时,复用会退化成「拿到的都是死连接」。所以引擎复用必须
|
//! 池里的空闲连接就属于已销毁的运行时,复用会退化成「拿到的都是死连接」。所以引擎复用必须配
|
||||||
//! 配运行时常驻(`blocking` 模块那头一并改成进程内唯一运行时)。两者是同一件事的两半。
|
//! 运行时常驻(`blocking` 那一头一并改成进程内唯一运行时)。两者是同一件事的两半。
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
|
||||||
use super::cache::{now_ms, ContentCache};
|
use super::cache::{now_ms, ContentCache};
|
||||||
use super::config::EngineConfig;
|
use super::config::{publish, wanted, DownloadTuning, EngineConfig};
|
||||||
use super::engine::DownloadEngine;
|
use super::engine::DownloadEngine;
|
||||||
use super::health::HealthRegistry;
|
use super::health::HealthRegistry;
|
||||||
use super::mirrors::MirrorTable;
|
use super::mirrors::MirrorTable;
|
||||||
@@ -102,9 +102,8 @@ pub fn engine_for(cache_root: impl AsRef<Path>) -> Arc<DownloadEngine> {
|
|||||||
/// 让该数据根的共享引擎失效(下次 [`engine_for`] 重新构造)。
|
/// 让该数据根的共享引擎失效(下次 [`engine_for`] 重新构造)。
|
||||||
///
|
///
|
||||||
/// **只让登记处不再持有那一份**:已经拿到 `Arc` 的人继续用旧的。短命请求自然结束;长命持有者
|
/// **只让登记处不再持有那一份**:已经拿到 `Arc` 的人继续用旧的。短命请求自然结束;长命持有者
|
||||||
/// (presenter 的自更新 `Factory`)必须自己重建,否则它会一直用旧实例——本仓在「重新检测地区」
|
/// (presenter 的自更新 `Factory`)必须自己重建——「重新检测地区」那条设置动作就补调了
|
||||||
/// 的设置动作里调 `CoreHost::refresh_update_factory()` 补上这一步(见模块文档「`forget` 到什么范围」)。
|
/// `CoreHost::refresh_update_factory()`。返回是否真淘汰了一份。
|
||||||
/// 返回是否真淘汰了一份。
|
|
||||||
pub fn forget(cache_root: impl AsRef<Path>) -> bool {
|
pub fn forget(cache_root: impl AsRef<Path>) -> bool {
|
||||||
registry()
|
registry()
|
||||||
.lock()
|
.lock()
|
||||||
@@ -116,9 +115,7 @@ pub fn forget(cache_root: impl AsRef<Path>) -> bool {
|
|||||||
/// 记一笔「这个缓存根上又造了一个引擎」。
|
/// 记一笔「这个缓存根上又造了一个引擎」。
|
||||||
///
|
///
|
||||||
/// **唯一调用点是 [`DownloadEngine::new`]**(不是 [`engine_for`]):记账卡在构造这个卡口上,
|
/// **唯一调用点是 [`DownloadEngine::new`]**(不是 [`engine_for`]):记账卡在构造这个卡口上,
|
||||||
/// 于是 [`constructions`] 统计的是**进程里真实发生过几次构造**,与「谁造的」无关。
|
/// [`constructions`] 才是「进程里真实构造过几次」,与谁造的无关(M165 复审备注 2)。
|
||||||
/// 若只在 `engine_for` 里记账,任何绕过登记处、自己 `DownloadEngine::new` 的调用点都统计不到
|
|
||||||
/// (M165 复审备注 2 的诉求)。
|
|
||||||
pub(crate) fn record_build(cache_root: &Path) {
|
pub(crate) fn record_build(cache_root: &Path) {
|
||||||
*ledger()
|
*ledger()
|
||||||
.lock()
|
.lock()
|
||||||
@@ -127,10 +124,8 @@ pub(crate) fn record_build(cache_root: &Path) {
|
|||||||
.or_default() += 1;
|
.or_default() += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 该数据根在本进程里**实际构造过几次引擎**(不论谁构造的)。
|
/// 该数据根在本进程里**实际构造过几次引擎**(不论谁构造的):复用成立时停在 `1`(`forget`
|
||||||
///
|
/// 之后每重建一次 +1)。只断言「结果一样」测不出复用——引擎建几次都能下同一个文件。
|
||||||
/// 这是给守卫与诊断用的:跨请求复用成立时,同一个根无论被请求多少次,这个数都停在 `1`
|
|
||||||
/// (`forget` 之后每重建一次 +1)。只断言「结果一样」测不出复用——引擎建几次都能下同一个文件。
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn constructions(cache_root: impl AsRef<Path>) -> usize {
|
pub fn constructions(cache_root: impl AsRef<Path>) -> usize {
|
||||||
ledger()
|
ledger()
|
||||||
@@ -141,9 +136,13 @@ pub fn constructions(cache_root: impl AsRef<Path>) -> usize {
|
|||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 真实构造(**唯一**一处 `DownloadEngine::new` 的宿主侧装配)。
|
/// 真实构造(**presenter 侧**唯一一处 `DownloadEngine::new` 的宿主侧装配;CLI 还有三处自建,
|
||||||
|
/// 见 `docs/download-settings.md` §3 的「哪些路径吃这套设置」)。装配参数从该数据根的 [`wanted`] 取,
|
||||||
|
/// 不用裸 `EngineConfig::default()`(M193):并发 → `Semaphore` 容量、尝试次数 → `attempts_for()`
|
||||||
|
/// 上限、gh_proxy → 地区档案(`expand()` 只认档案里那个前缀)。值由宿主经 [`set_tuning`] 灌入。
|
||||||
fn build(cache_root: &Path) -> DownloadEngine {
|
fn build(cache_root: &Path) -> DownloadEngine {
|
||||||
let now = now_ms();
|
let now = now_ms();
|
||||||
|
let tune = wanted(cache_root);
|
||||||
let region_path = cache_root.join("region.json");
|
let region_path = cache_root.join("region.json");
|
||||||
let region = load_cached(®ion_path, now).unwrap_or_else(|| {
|
let region = load_cached(®ion_path, now).unwrap_or_else(|| {
|
||||||
let profile = decide_from(&detect_signals(), now);
|
let profile = decide_from(&detect_signals(), now);
|
||||||
@@ -151,19 +150,35 @@ fn build(cache_root: &Path) -> DownloadEngine {
|
|||||||
profile
|
profile
|
||||||
});
|
});
|
||||||
DownloadEngine::new(
|
DownloadEngine::new(
|
||||||
EngineConfig::default(),
|
tune.apply_to(EngineConfig::default()),
|
||||||
Arc::new(MirrorTable::builtin()),
|
Arc::new(MirrorTable::builtin()),
|
||||||
Arc::new(region),
|
Arc::new(tune.apply_gh_proxy(region)),
|
||||||
Arc::new(HealthRegistry::open_in(cache_root, now)),
|
Arc::new(HealthRegistry::open_in(cache_root, now)),
|
||||||
Arc::new(ContentCache::open(cache_root)),
|
Arc::new(ContentCache::open(cache_root)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 换该数据根的下载调优(宿主取引擎前 / 设置变更时调用),返回**是否真的变了**。变了就清掉该根的
|
||||||
|
/// 登记项,下次 [`engine_for`] 按新参数重建——否则「改了设置要重启才生效」。已拿到 `Arc` 的长命
|
||||||
|
/// 持有者继续用旧的(与 [`forget`] 同一条边界);自更新 `Factory` 由 dispatch 侧换引擎。
|
||||||
|
/// **锁序**:`publish` 的锁先释放再拿登记处的锁,[`build`] 反之——两段不嵌套。
|
||||||
|
pub fn set_tuning(cache_root: impl AsRef<Path>, next: DownloadTuning) -> bool {
|
||||||
|
let key = key_of(cache_root.as_ref());
|
||||||
|
if !publish(&key, next) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let _ = registry()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
.remove(&key);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// 每个用例一个独立数据根(登记处是进程级的,键撞了会互相干扰)。
|
/// 每个用例一个独立数据根(登记处与调优都是进程级、按根存,键撞了会互相干扰)。
|
||||||
fn root(tag: &str) -> PathBuf {
|
fn root(tag: &str) -> PathBuf {
|
||||||
static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||||
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||||
@@ -308,4 +323,74 @@ mod tests {
|
|||||||
assert_eq!(conns, 1, "两次下载应复用同一条连接");
|
assert_eq!(conns, 1, "两次下载应复用同一条连接");
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **守门①②**:设置说 2 ⇒ 引擎**真实的并发闸门**就是 2(探针读 `Semaphore` 而非配置);gh-proxy
|
||||||
|
/// 三档也真的改变 `plan()` 的候选链;`retry_limit = 0` 落成「只试一次」。
|
||||||
|
///
|
||||||
|
/// 变异证明(只改实现侧):① `build` 换回裸 `EngineConfig::default()` ⇒ 探针 8 红;②
|
||||||
|
/// `apply_gh_proxy(region)` 换回 `region` ⇒ `off` 档仍有候选,红;③ 删 `set_tuning` 的
|
||||||
|
/// `remove` ⇒ 换参数后交回旧引擎,红。gh-proxy 的端到端(schema 键 → 调优)等 M197 登记该键。
|
||||||
|
#[test]
|
||||||
|
fn the_setting_and_the_gh_proxy_override_reach_the_engine() {
|
||||||
|
use super::super::types::{DownloadRequest, ResourceKind};
|
||||||
|
use super::super::{config, RegionProfile};
|
||||||
|
|
||||||
|
let dir = root("tune");
|
||||||
|
// 镜像优先的档案:gh-proxy 只在这个模式下才生成候选。
|
||||||
|
store_cached(
|
||||||
|
&dir.join("region.json"),
|
||||||
|
&RegionProfile::mirror_first(now_ms()),
|
||||||
|
)
|
||||||
|
.expect("地区档案");
|
||||||
|
let req = DownloadRequest::new(
|
||||||
|
ResourceKind::Other,
|
||||||
|
"https://github.com/o/r/releases/download/v1/a.jar",
|
||||||
|
dir.join("a.jar"),
|
||||||
|
);
|
||||||
|
let chain = |dir: &Path| -> Vec<String> {
|
||||||
|
engine_for(dir)
|
||||||
|
.plan(&req, 0)
|
||||||
|
.into_iter()
|
||||||
|
.map(|c| c.url)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let set = |c: Option<i64>, a: Option<i64>, g: &str| {
|
||||||
|
set_tuning(&dir, DownloadTuning::from_settings(c, a, Some(g)))
|
||||||
|
};
|
||||||
|
|
||||||
|
set(Some(2), Some(1), "off");
|
||||||
|
let engine = engine_for(&dir);
|
||||||
|
assert_eq!(engine.concurrency_probe(), 2, "闸门必须真是 2 张许可");
|
||||||
|
let off = chain(&dir);
|
||||||
|
assert!(!off.iter().any(|u| u.contains("gh-proxy")), "{off:?}");
|
||||||
|
|
||||||
|
set(Some(5), None, "https://gh.example/");
|
||||||
|
let rebuilt = engine_for(&dir);
|
||||||
|
assert!(!Arc::ptr_eq(&engine, &rebuilt), "参数变了必须换引擎");
|
||||||
|
assert_eq!(rebuilt.concurrency_probe(), 5, "新闸门吃新值");
|
||||||
|
let over = chain(&dir);
|
||||||
|
assert!(
|
||||||
|
over.iter().any(|u| u.starts_with("https://gh.example/")),
|
||||||
|
"{over:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// P2-1:`retry_limit = 0`(旧配置里真有)不许回落出厂 3。硬上限:塞 9999 也只开 64。
|
||||||
|
set(Some(8), Some(0), "");
|
||||||
|
let zero = engine_for(&dir);
|
||||||
|
assert_eq!(zero.config().max_attempts_per_source, 1);
|
||||||
|
assert_eq!(
|
||||||
|
zero.attempts_for("official", ResourceKind::Other, now_ms()),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
let wild = DownloadTuning {
|
||||||
|
max_concurrency: 9_999,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
set_tuning(&dir, wild);
|
||||||
|
assert_eq!(
|
||||||
|
engine_for(&dir).concurrency_probe(),
|
||||||
|
config::MAX_CONCURRENCY
|
||||||
|
);
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -104,17 +104,17 @@ pub fn launcher_schema() -> SettingSchema {
|
|||||||
))
|
))
|
||||||
.with(SettingSpec::int(
|
.with(SettingSpec::int(
|
||||||
"download.concurrency",
|
"download.concurrency",
|
||||||
8,
|
crate::download::config::DEFAULT_MAX_CONCURRENCY as i64,
|
||||||
(1, 256),
|
(1, crate::download::config::MAX_CONCURRENCY as i64),
|
||||||
AiPolicy::silent(),
|
AiPolicy::silent(),
|
||||||
"下载并发数",
|
"同时下载的文件数(1~64,出厂 8)。这是引擎真实持有的并发许可,不是展示值;越界或手改坏值按出厂值生效",
|
||||||
))
|
))
|
||||||
.with(SettingSpec::int(
|
.with(SettingSpec::int(
|
||||||
"download.retry_limit",
|
"download.retry_limit",
|
||||||
3,
|
crate::download::config::DEFAULT_MAX_ATTEMPTS_PER_SOURCE as i64,
|
||||||
(0, 10),
|
(0, crate::download::config::MAX_ATTEMPTS_PER_SOURCE as i64),
|
||||||
AiPolicy::silent(),
|
AiPolicy::silent(),
|
||||||
"单个文件重试次数",
|
"单个下载源的最多尝试次数(含首次;出厂 3)。0 与 1 都等于「只试一次、不重试」——0 是旧版 GUI 提供过的档位,保留合法但不代表「不下载」。换源不受此限",
|
||||||
))
|
))
|
||||||
.with(SettingSpec::enumeration(
|
.with(SettingSpec::enumeration(
|
||||||
"download.region",
|
"download.region",
|
||||||
|
|||||||
@@ -122,8 +122,13 @@ fn pinned_or_disabled_gh_proxy_is_respected() {
|
|||||||
.expect("自定义前缀未生效");
|
.expect("自定义前缀未生效");
|
||||||
assert_eq!(proxy.url, format!("https://proxy.example/{GITHUB_URL}"));
|
assert_eq!(proxy.url, format!("https://proxy.example/{GITHUB_URL}"));
|
||||||
|
|
||||||
// 清单给设置做默认值。
|
// 「默认前缀」的唯一真相源是**地区档案**(M193:`MirrorTable::set_gh_proxy` /
|
||||||
assert_eq!(table.default_gh_proxy(), Some("https://gh-proxy.com/"));
|
// `default_gh_proxy` 已删除——它们改/读的是表里的前缀目录,而 `expand()` 读档案,
|
||||||
|
// 属于「看起来能改、其实没人读」。这条断言从此钉在档案上)。
|
||||||
|
assert_eq!(
|
||||||
|
RegionProfile::mirror_first(0).gh_proxy.as_deref(),
|
||||||
|
Some(unlucky_core::download::region::DEFAULT_GH_PROXY)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -226,6 +226,11 @@ impl CoreHost {
|
|||||||
if key == super::engine::DETECT_REGION_ACTION {
|
if key == super::engine::DETECT_REGION_ACTION {
|
||||||
self.refresh_update_factory();
|
self.refresh_update_factory();
|
||||||
}
|
}
|
||||||
|
// M193:下载三键(并发/重试/gh-proxy)改了要立刻换引擎参数。这是**显式**的同步点
|
||||||
|
// ——不藏在任何读函数里。值没变时它一行不动。
|
||||||
|
if settings::download::apply_tuning(&self.env) {
|
||||||
|
self.refresh_update_factory();
|
||||||
|
}
|
||||||
self.send(CoreEvent::SettingsChanged(settings::groups(&self.env)));
|
self.send(CoreEvent::SettingsChanged(settings::groups(&self.env)));
|
||||||
self.java_chain.clear();
|
self.java_chain.clear();
|
||||||
self.refresh_java();
|
self.refresh_java();
|
||||||
|
|||||||
@@ -22,8 +22,14 @@ use super::env::HostEnv;
|
|||||||
/// 取该数据根上**共享**的下载引擎(缓存/健康度/地区判定都在数据根里)。
|
/// 取该数据根上**共享**的下载引擎(缓存/健康度/地区判定都在数据根里)。
|
||||||
///
|
///
|
||||||
/// 同一个数据根无论被请求多少次,都是同一个引擎;不同数据根各一份(缓存/健康度不串味)。
|
/// 同一个数据根无论被请求多少次,都是同一个引擎;不同数据根各一份(缓存/健康度不串味)。
|
||||||
|
///
|
||||||
|
/// **取引擎前先把下载设置灌进 core 的下载调优**(M193,按数据根存):`download.concurrency` /
|
||||||
|
/// `download.retry_limit` / `download.gh_proxy` 变了就重建引擎(值没变时 [`settings::download::apply_tuning`]
|
||||||
|
/// 一行不动,所以每次调用都灌是安全的)。这是「用户改了设置 ⇒ 下一次下载真的按新值跑」的落点:
|
||||||
|
/// 不在这里灌,进程级引擎会带着启动时那份参数活到进程结束——那正是「设置页看着有、实际没用」。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn engine(env: &HostEnv) -> Arc<DownloadEngine> {
|
pub fn engine(env: &HostEnv) -> Arc<DownloadEngine> {
|
||||||
|
super::settings::download::apply_tuning(env);
|
||||||
shared::engine_for(env.layout.cache())
|
shared::engine_for(env.layout.cache())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +126,11 @@ mod tests {
|
|||||||
let dir = root("cache");
|
let dir = root("cache");
|
||||||
let env = HostEnv::at_root(&dir);
|
let env = HostEnv::at_root(&dir);
|
||||||
let engine = engine(&env);
|
let engine = engine(&env);
|
||||||
assert_eq!(engine.config().max_concurrency, 64);
|
assert_eq!(
|
||||||
|
engine.config().max_concurrency,
|
||||||
|
unlucky_core::download::config::DEFAULT_MAX_CONCURRENCY
|
||||||
|
);
|
||||||
|
assert_eq!(engine.concurrency_probe(), engine.config().max_concurrency);
|
||||||
// 地区判定不联网:只读缓存/信号
|
// 地区判定不联网:只读缓存/信号
|
||||||
let _ = domestic(&env);
|
let _ = domestic(&env);
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
|||||||
@@ -13,10 +13,14 @@ use crate::dto::{SettingGroupView, SettingRowView};
|
|||||||
|
|
||||||
use super::env::HostEnv;
|
use super::env::HostEnv;
|
||||||
|
|
||||||
|
// M193:下载组的行与「设置 → 引擎调优」的翻译搬进子模块(`settings.rs` 顶在 400 行硬线上,
|
||||||
|
// 理由与 `settings_tests.rs` 同源,见那个文件的文件头)。
|
||||||
|
pub(crate) mod download;
|
||||||
|
|
||||||
/// 全部设置分组(设置页右栏一次拿全,切分组不再请求)。
|
/// 全部设置分组(设置页右栏一次拿全,切分组不再请求)。
|
||||||
pub fn groups(env: &HostEnv) -> Vec<SettingGroupView> {
|
pub fn groups(env: &HostEnv) -> Vec<SettingGroupView> {
|
||||||
vec![
|
vec![
|
||||||
download_group(env),
|
download::group(env),
|
||||||
java_group(env),
|
java_group(env),
|
||||||
data_group(env),
|
data_group(env),
|
||||||
ai_group(env),
|
ai_group(env),
|
||||||
@@ -58,89 +62,6 @@ fn selected(options: &[&str], value: &str) -> i32 {
|
|||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 下载(D7):并发/重试/地区/镜像偏好/代理。
|
|
||||||
fn download_group(env: &HostEnv) -> SettingGroupView {
|
|
||||||
let concurrency = int(env, "download.concurrency", 8).to_string();
|
|
||||||
let retry = int(env, "download.retry_limit", 3).to_string();
|
|
||||||
let concurrency_options = ["4", "8", "16"];
|
|
||||||
let retry_options = ["0", "1", "3", "5"];
|
|
||||||
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(
|
|
||||||
"download.concurrency",
|
|
||||||
"并发数",
|
|
||||||
"同时下载的文件数(海量小文件,16 路足够压满带宽)",
|
|
||||||
&concurrency_options,
|
|
||||||
selected(&concurrency_options, &concurrency),
|
|
||||||
))
|
|
||||||
.with(SettingRowView::choice(
|
|
||||||
"download.retry_limit",
|
|
||||||
"单文件重试次数",
|
|
||||||
"失败后换源重试的上限",
|
|
||||||
&retry_options,
|
|
||||||
selected(&retry_options, &retry),
|
|
||||||
))
|
|
||||||
.with(SettingRowView::choice(
|
|
||||||
"download.region",
|
|
||||||
"地区判定",
|
|
||||||
"auto = 时区+语言+探测;决定是否优先国内镜像",
|
|
||||||
®ions,
|
|
||||||
selected(
|
|
||||||
®ions,
|
|
||||||
if region_value.is_empty() {
|
|
||||||
"auto"
|
|
||||||
} else {
|
|
||||||
®ion_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"),
|
|
||||||
))
|
|
||||||
.with(SettingRowView::action(
|
|
||||||
super::engine::DETECT_REGION_ACTION,
|
|
||||||
"重新检测地区",
|
|
||||||
"清掉 24h 地区缓存,下次下载重新判定",
|
|
||||||
"detect_region",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Java(D13/D23):自动安装/内存/选路/发行版优先级。
|
/// Java(D13/D23):自动安装/内存/选路/发行版优先级。
|
||||||
fn java_group(env: &HostEnv) -> SettingGroupView {
|
fn java_group(env: &HostEnv) -> SettingGroupView {
|
||||||
let memory = int(env, "java.default_memory_mb", 2048).to_string();
|
let memory = int(env, "java.default_memory_mb", 2048).to_string();
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
//! 设置页「下载」分组 + 下载调优的**宿主侧翻译**(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 ─┼─→ DownloadTuning(core 决定默认值与夹取)─→ shared::set_tuning
|
||||||
|
//! download.gh_proxy ───┘ (引擎重建时生效)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! **调优的同步点是显式的**:`host/engine.rs` 每次取引擎前、`dispatch.rs` 的 `SetSetting` 分支里,
|
||||||
|
//! 都调 [`apply_tuning`](幂等:值没变就什么都不做)。它**不是**藏在某个读函数里的副作用——
|
||||||
|
//! 「读起来是纯读、实际改全局状态」正是本仓吃过亏的那类形状。
|
||||||
|
|
||||||
|
use unlucky_core::download::config::{
|
||||||
|
DownloadTuning, 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::{int, 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 加速前缀。
|
||||||
|
pub(super) fn group(env: &HostEnv) -> SettingGroupView {
|
||||||
|
let concurrency = int(
|
||||||
|
env,
|
||||||
|
unlucky_core::download::config::KEY_CONCURRENCY,
|
||||||
|
DEFAULT_MAX_CONCURRENCY as i64,
|
||||||
|
)
|
||||||
|
.to_string();
|
||||||
|
let retry = int(
|
||||||
|
env,
|
||||||
|
unlucky_core::download::config::KEY_RETRY_LIMIT,
|
||||||
|
i64::from(DEFAULT_MAX_ATTEMPTS_PER_SOURCE),
|
||||||
|
)
|
||||||
|
.to_string();
|
||||||
|
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,
|
||||||
|
"并发数",
|
||||||
|
&format!(
|
||||||
|
"同时下载的文件数:出厂 {DEFAULT_MAX_CONCURRENCY},可调 1~{MAX_CONCURRENCY}(引擎硬上限,防止填 9999 把机器拖死);越界或手改坏值按出厂值生效"
|
||||||
|
),
|
||||||
|
&concurrency_options,
|
||||||
|
selected(&concurrency_options, &concurrency),
|
||||||
|
))
|
||||||
|
.with(SettingRowView::choice(
|
||||||
|
unlucky_core::download::config::KEY_RETRY_LIMIT,
|
||||||
|
"单源尝试次数",
|
||||||
|
&format!(
|
||||||
|
"同一个源最多试几次(含首次;出厂 {DEFAULT_MAX_ATTEMPTS_PER_SOURCE})。0 与 1 都等于「只试一次、不重试」——0 是旧版提供的档位,不代表「不下载」。换源不受此限"
|
||||||
|
),
|
||||||
|
&retry_options,
|
||||||
|
selected(&retry_options, &retry),
|
||||||
|
))
|
||||||
|
.with(SettingRowView::choice(
|
||||||
|
"download.region",
|
||||||
|
"地区判定",
|
||||||
|
"auto = 时区+语言+探测;决定是否优先国内镜像",
|
||||||
|
®ions,
|
||||||
|
selected(
|
||||||
|
®ions,
|
||||||
|
if region_value.is_empty() {
|
||||||
|
"auto"
|
||||||
|
} else {
|
||||||
|
®ion_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 是两件事**,描述里必须都写出来——用户最容易把
|
||||||
|
// 「我还没决定」当成「我不要加速」,于是以为自己关掉了、实际还在套前缀。
|
||||||
|
.with(SettingRowView::text(
|
||||||
|
unlucky_core::download::config::KEY_GH_PROXY,
|
||||||
|
"GitHub 加速前缀(gh-proxy)",
|
||||||
|
"留空 = 跟随内置默认(镜像优先/国内判定时自动套 gh-proxy.com);填 off = 关闭(不生成任何 gh-proxy 候选);填一个 URL 前缀 = 覆盖它",
|
||||||
|
&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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 `launcher.json` 读出下载调优(默认值与夹取口径在 core,本函数只做「取原始值」)。
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn tuning_of(env: &HostEnv) -> DownloadTuning {
|
||||||
|
use unlucky_core::download::config::{KEY_CONCURRENCY, KEY_GH_PROXY, KEY_RETRY_LIMIT};
|
||||||
|
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()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把当前设置灌进 core 的下载调优(按数据根),返回**是否真的变了**(变了就失效该根的引擎)。
|
||||||
|
///
|
||||||
|
/// 幂等:值没变时 `shared::set_tuning` 一行不动,所以「每次取引擎前都调」是安全的。
|
||||||
|
pub(crate) fn apply_tuning(env: &HostEnv) -> bool {
|
||||||
|
shared::set_tuning(env.layout.cache(), tuning_of(env))
|
||||||
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
//! host/settings.rs 的单测(拆文件守 400 行上限)。
|
//! host/settings.rs 的单测(拆文件守 400 行上限)。
|
||||||
|
//!
|
||||||
|
//! **为什么单独一个文件**:`settings.rs` 正好顶在 400 行硬线上,而「设置写进去到底有没有生效」
|
||||||
|
//! 这一类用例恰恰需要反复构造数据根 + 读写 `launcher.json`,塞回生产文件就只剩砍论证注释这一条路。
|
||||||
|
//! 本文件与它守的代码同目录、同名族(`settings.rs` 的 `#[path]` 挂载),**不是**随手放的杂项目。
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
@@ -7,6 +11,65 @@ fn env_in(dir: &std::path::Path) -> HostEnv {
|
|||||||
HostEnv::at_root(dir)
|
HostEnv::at_root(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **M193 守门**:把设置改成 2 ⇒ **下一次取到的引擎**的并发闸门真是 2(探针读 `Semaphore`)。
|
||||||
|
///
|
||||||
|
/// 这是「设置项说假话」那条缺陷族的正面守门。断言分三段,缺一段都守不住:
|
||||||
|
///
|
||||||
|
/// 1. 写设置(走 `SetSetting` 同一条 `settings::set`);
|
||||||
|
/// 2. 走宿主真正的取引擎入口 `host::engine::engine(env)`(它负责把设置灌进 core 调优);
|
||||||
|
/// 3. 读**引擎的真实闸门** `concurrency_probe()`,不是 `config().max_concurrency`。
|
||||||
|
///
|
||||||
|
/// 变异证明(只改实现侧):
|
||||||
|
/// * 把 `host/engine.rs::engine` 里那行 `apply_tuning` 删掉 ⇒ 引擎按出厂 8 建,探针读到 8,红;
|
||||||
|
/// * 把 `core::download::shared::build` 里的 `tune.apply_to(EngineConfig::default())` 换回
|
||||||
|
/// `EngineConfig::default()` ⇒ 同样读到 8,红;
|
||||||
|
/// * 把 `settings/download.rs::tuning_of` 读错键名(例如 `download.threads`)⇒ 读到默认 8,红。
|
||||||
|
///
|
||||||
|
/// `download.gh_proxy` 那一路**没有**端到端用例:那个键由 M197 登记,尚未合入 dev,而
|
||||||
|
/// `setting::document` 只认已登记的键(未登记的键进不了 `effective()`),所以这里写进文件也读不出来。
|
||||||
|
/// core 侧有「调优三档 → 候选链」的守卫(`download/shared.rs`),端到端那一半登记为 M197 的 follow-up。
|
||||||
|
#[test]
|
||||||
|
fn the_concurrency_setting_reaches_the_real_download_engine() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("ull-host-tune-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
let env = env_in(&dir);
|
||||||
|
|
||||||
|
set(&env, "download.concurrency", "2").expect("写并发");
|
||||||
|
set(&env, "download.retry_limit", "1").expect("写尝试次数");
|
||||||
|
|
||||||
|
let engine = crate::host::engine::engine(&env);
|
||||||
|
assert_eq!(engine.concurrency_probe(), 2, "闸门必须真是 2 张许可");
|
||||||
|
assert_eq!(
|
||||||
|
engine.config().max_attempts_per_source,
|
||||||
|
1,
|
||||||
|
"尝试次数也要吃到设置"
|
||||||
|
);
|
||||||
|
|
||||||
|
// P2-1:`retry_limit = 0`(旧配置里真有)必须写得回去、且引擎真的只试一次(不是出厂 3)。
|
||||||
|
set(&env, "download.retry_limit", "0").expect("0 是合法值(旧版档位)");
|
||||||
|
let zero = crate::host::engine::engine(&env);
|
||||||
|
println!(
|
||||||
|
"retry_limit=0 => store={:?} engine.config().max_attempts_per_source={}",
|
||||||
|
env.store().doc().effective_i64("download.retry_limit"),
|
||||||
|
zero.config().max_attempts_per_source
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
zero.config().max_attempts_per_source,
|
||||||
|
1,
|
||||||
|
"0 必须落成「只试一次」,不许回落出厂 3"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 改回去 ⇒ 下一次取到的引擎跟着换(不是「重启才生效」)。
|
||||||
|
set(&env, "download.concurrency", "16").expect("再写并发");
|
||||||
|
let rebuilt = crate::host::engine::engine(&env);
|
||||||
|
assert_eq!(rebuilt.concurrency_probe(), 16);
|
||||||
|
assert!(
|
||||||
|
!std::sync::Arc::ptr_eq(&engine, &rebuilt),
|
||||||
|
"参数变了必须换引擎,否则新值落不到在飞的那份上"
|
||||||
|
);
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn groups_cover_the_six_required_areas() {
|
fn groups_cover_the_six_required_areas() {
|
||||||
let dir = std::env::temp_dir().join(format!("ull-host-settings-{}", std::process::id()));
|
let dir = std::env::temp_dir().join(format!("ull-host-settings-{}", std::process::id()));
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# 下载设置到底改了谁(M193)
|
||||||
|
|
||||||
|
一句话:**`download.concurrency` / `download.retry_limit` / `download.gh_proxy` 现在真的进引擎**;
|
||||||
|
其余四个 `download.*` 键(`region` / `mirror_preference` / `proxy_mode` / `proxy_url`)**引擎侧零消费者**,
|
||||||
|
界面上改了不会改变任何下载行为——这一页把它们逐条标出来,别再让「设置项说假话」。
|
||||||
|
|
||||||
|
出处:M190 的 204 条 finding 复核(`DevDocs/PlanDoc/research/191-finding-triage.md` §3 A-3)挖出的
|
||||||
|
「用户能改、改了没用」缺口:schema 说并发 8,引擎恒跑 64,两处各写一个数。
|
||||||
|
|
||||||
|
## 1. 取证:八个下载类键的真实生效值
|
||||||
|
|
||||||
|
「真实生效值」是**改造前**(M193 之前)代码里实际会用的值,不是界面上写的值。
|
||||||
|
|
||||||
|
| 键 | schema 默认 / 范围 | 引擎侧消费者 | 真实生效值(改造前) | 本轮 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `download.concurrency` | 8 / 1–256 | **无**(恒用 `EngineConfig::default()`) | **64**(`Semaphore` 容量) | ✅ 接进引擎,范围收成 1–64 |
|
||||||
|
| `download.retry_limit` | 3 / 0–10 | **无** | 3(碰巧与 schema 相同,但改它无效) | ✅ 接进引擎,范围 0–10(0 与 1 都 = 只试一次) |
|
||||||
|
| `download.gh_proxy` | (M197 登记,dev 上尚无此键) | JDK 作业那条(M197 自建引擎时覆盖档案) | 地区档案里的内置值(镜像优先时 `gh-proxy.com`) | ✅ 下沉到共享装配点,所有下载路径一致 |
|
||||||
|
| `download.region` | auto / auto·cn·global | **无**(`RegionProfile::pinned` 无生产调用点) | 永远按「时区+语言」自动判定 | ❌ 未接(见 §5) |
|
||||||
|
| `download.mirror_preference` | auto / auto·official·mirror | **无** | 永远按地区模式(镜像优先/直连) | ❌ 未接(见 §5) |
|
||||||
|
| `download.proxy_mode` | none / none·system·manual | **无**(`http::build_client` 从不调 `.proxy()`) | 只走环境变量代理(reqwest 默认),`system`/`manual` 都不生效 | ❌ 未接(见 §5) |
|
||||||
|
| `download.proxy_url` | "" | **无** | 空(从不使用) | ❌ 未接(见 §5) |
|
||||||
|
| `download.action.detect_region` | (动作键,不是设置) | presenter 的 `detect_region` | 清 `region.json` + 失效共享引擎 | ✅ 本来就真 |
|
||||||
|
|
||||||
|
复现方法(改造前):`grep -rn "download.concurrency" crates/` 只命中 `setting/keys.rs` 的定义与
|
||||||
|
agent 的测试;同理 `download.proxy_mode` / `proxy_url` / `mirror_preference` 只有 presenter 的显示行。
|
||||||
|
|
||||||
|
## 2. 唯一来源:默认值谁说了算
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/core/src/download/config.rs
|
||||||
|
DEFAULT_MAX_CONCURRENCY = 8 ← 唯一写死数字的地方
|
||||||
|
MAX_CONCURRENCY = 64 ← 硬上限
|
||||||
|
DEFAULT_MAX_ATTEMPTS_PER_SOURCE = 3
|
||||||
|
MAX_ATTEMPTS_PER_SOURCE = 10
|
||||||
|
│
|
||||||
|
├─→ EngineConfig::default() (引擎默认)
|
||||||
|
├─→ DownloadTuning::default() (宿主没灌任何值时)
|
||||||
|
└─→ setting::keys 的 schema 默认值与范围 (界面/AI/CLI 看到的契约)
|
||||||
|
```
|
||||||
|
|
||||||
|
守卫 `the_schema_contract_is_single_sourced`(`download/config.rs`,它合并了原先的「默认值相等」与
|
||||||
|
「区间同源」两条)让「改一处另一个不变」直接变红——这是防 8 vs 64 复发的唯一手段。
|
||||||
|
另有 `the_key_docs_quote_the_real_limits` 钉住 schema 描述里的数字(见 §6.1 的第三处字面量)。
|
||||||
|
|
||||||
|
## 3. 接线链(宿主 → 引擎)
|
||||||
|
|
||||||
|
```text
|
||||||
|
config/launcher.json
|
||||||
|
download.concurrency ─┐
|
||||||
|
download.retry_limit ─┼→ presenter::host::settings::download::tuning_of
|
||||||
|
download.gh_proxy ────┘ │ DownloadTuning::from_settings(默认值/夹取/回落都在 core)
|
||||||
|
↓
|
||||||
|
shared::set_tuning(<cache 根>, tuning) 变了吗?没变就什么都不做
|
||||||
|
↓ 变了 → 淘汰该根的登记项
|
||||||
|
shared::engine_for(<cache 根>) → shared::build
|
||||||
|
↓
|
||||||
|
EngineConfig { max_concurrency, max_attempts_per_source } + RegionProfile.gh_proxy
|
||||||
|
↓
|
||||||
|
Semaphore(容量) / attempts_for() / MirrorTable::expand() 的 gh-proxy 候选
|
||||||
|
```
|
||||||
|
|
||||||
|
**同步点是显式的**,两处,共用同一个函数 `apply_tuning`:
|
||||||
|
|
||||||
|
* `presenter/src/host/engine.rs::engine()` —— 每次取引擎前(幂等,值没变就一行不动);
|
||||||
|
* `presenter/src/host/dispatch.rs` 的 `SetSetting` 分支 —— 用户改完设置立刻生效,并让常驻持有
|
||||||
|
引擎的自更新 `Factory` 换一份(`refresh_update_factory`)。
|
||||||
|
|
||||||
|
调优按**数据根**存(与引擎登记处同键),不是进程级:同一进程里多个数据根时互不串味。
|
||||||
|
|
||||||
|
## 3.1 哪些路径吃这套设置(哪些不吃)
|
||||||
|
|
||||||
|
| 路径 | 入口 | 吃设置吗 |
|
||||||
|
|---|---|---|
|
||||||
|
| GUI 的**全部**下载:内容库(Modrinth/CurseForge)、版本清单、资源/库/日志文件、字体、JDK 安装、自更新 | `presenter::host::engine::engine` → `core::download::shared::engine_for` | ✅ 吃(并发/尝试次数/gh-proxy 都吃) |
|
||||||
|
| CLI `ull install` | `crates/cli/src/commands/install/mod.rs:95` 自建 `DownloadEngine::new(EngineConfig::default())` | ❌ **不吃**:并发与尝试次数是**出厂值**(8 / 3),不是用户设置值 |
|
||||||
|
| CLI `ull java` | `crates/cli/src/commands/java.rs:326` 同上 | ❌ 不吃 |
|
||||||
|
| CLI `ull pack` | `crates/cli/src/commands/pack.rs:286` 同上 | ❌ 不吃 |
|
||||||
|
|
||||||
|
也就是说 `shared::build` 是 **presenter 侧**唯一一处宿主装配,但**不是全仓唯一**:CLI 有三处自建引擎,
|
||||||
|
它们在 M193 之前也不读设置(一直是裸 `EngineConfig::default()`),而 M193 把默认并发从 64 改成 8 之后,
|
||||||
|
`ull install` / `ull java` / `ull pack` 的并发**跟着从 64 变成 8**(出厂值变化,不是读到了设置)。
|
||||||
|
要让它们也吃设置,得改成走 `shared::engine_for` + 从 CLI 自己的 store 灌 tuning —— 已立 finding,
|
||||||
|
不在 M193 的 scope(`crates/cli`)。
|
||||||
|
|
||||||
|
## 4. 三态语义:`download.gh_proxy`
|
||||||
|
|
||||||
|
| 值 | 含义 | 效果 |
|
||||||
|
|---|---|---|
|
||||||
|
| 空串(出厂) | **没有覆盖**,跟随内置默认 | 镜像优先/国内判定时套 `mirrors.json` 里的 `gh-proxy.com` |
|
||||||
|
| `off`(也认 `none`/`direct`/`disable`/`disabled`/`no`) | 明确关掉 | 一个 gh-proxy 候选都不生成 |
|
||||||
|
| 其它非空 | 覆盖前缀 | 用它替换内置前缀(类别仍只在镜像优先/国内判定时启用) |
|
||||||
|
|
||||||
|
**空串 ≠ 关**:「我还没决定」和「我明确不要」是两件事。设置页的说明文案(presenter 行 + iced 行)
|
||||||
|
两档必须都写出来——这是最容易让用户以为「关掉了、其实还在套前缀」的地方。
|
||||||
|
|
||||||
|
边界:非法值(负数、超上限、手改坏值)**一律回落出厂默认**,不报错、不阻断下载;schema 在写入口
|
||||||
|
就拒绝越界值,引擎侧再夹一次是纵深防御(老文件里可能躺着旧 schema 允许的大数)。
|
||||||
|
|
||||||
|
**`download.retry_limit = 0` 是合法值**(P2-1 复审的结论):旧版 GUI 的档位表里就有 `0`,所以
|
||||||
|
范围保留 `0~10`,语义定为 **`0` 与 `1` 都 = 只试一次、不重试**(`attempts = max(1, value)`)。
|
||||||
|
这样老配置零迁移、`ull config get` 读到的 0 与引擎真做的事一致(试 1 次),不会再出现
|
||||||
|
「界面显示 0、引擎按 3 跑」。
|
||||||
|
|
||||||
|
## 5. 没接的四个键(已登记为 finding)
|
||||||
|
|
||||||
|
`download.region` / `download.mirror_preference` / `download.proxy_mode` / `download.proxy_url`
|
||||||
|
**引擎侧零消费者**:界面上改了不改变任何下载行为。要接它们分别需要:
|
||||||
|
|
||||||
|
* `region` → `RegionProfile::pinned(mode, now)`(值 `cn`/`global` 映射到 `MirrorFirst`/`Direct`,
|
||||||
|
并把 `trigger` 置成 `UserPinned` 让 24h 重判不覆盖它);
|
||||||
|
* `mirror_preference` → 引擎候选排序里插一档「强制官方优先 / 强制镜像优先」;
|
||||||
|
* `proxy_mode` / `proxy_url` → `http::build_client` 里加 `.proxy(reqwest::Proxy::all(url)?)`。
|
||||||
|
|
||||||
|
三条都在 M193 的 scope 外(`http.rs` / 引擎候选排序 / 一处新装配),已单独立 finding。
|
||||||
|
|
||||||
|
## 6. 守卫与变异证明(逐条只改实现侧,跑完即还原)
|
||||||
|
|
||||||
|
| # | 变异(只改实现侧) | 守卫用例 | 结果 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| M1 | `shared::build` 的 `tune.apply_to(...)` 换回裸 `EngineConfig::default()` | `download::shared::tests::the_setting_and_the_gh_proxy…` | 红(探针 8,期望 2) |
|
||||||
|
| M2 | `shared::build` 的 `tune.apply_gh_proxy(region)` 换回 `region` | 同上 | 红(`off` 档仍有 gh-proxy 候选) |
|
||||||
|
| M3 | 删掉 `set_tuning` 里的 `registry().remove` | 同上 | 红(换参数后交回旧引擎) |
|
||||||
|
| M4 | 删掉构造点的 `clamp(1, MAX_CONCURRENCY)` | 同上 | 红(9999 张许可真开了) |
|
||||||
|
| M5 | `EngineConfig::default()` 的并发写回字面量 64 | `download::config::tests::the_schema_contract_is_single_sourced` | 红 |
|
||||||
|
| M6 | schema 的并发默认值写成字面量 16 | 同上 | 红(两处各写一个数) |
|
||||||
|
| M7 | 删掉 `host/engine.rs::engine` 里的 `apply_tuning` | `host::settings::tests::the_concurrency_setting…` | 红(探针 8,期望 2) |
|
||||||
|
| M8 | 下载设置页的 gh-proxy 行改回 `("Preferred source", "Automatic (region)")` | `the_downloads_rows_state_semantics…` | 红 |
|
||||||
|
| M9 | `from_settings` 的尝试次数去掉 `.max(1)`(0 原样交给引擎) | `download::config::tests::missing_or_illegal_values_fall_back_to_the_defaults` | 红(`left: 0 / right: 1`) |
|
||||||
|
| M10 | schema 描述里的「1~64」改成「1~99」(第三处字面量) | `download::config::tests::the_key_docs_quote_the_real_limits` | 红 |
|
||||||
|
|
||||||
|
## 6.1 已知的观感问题(P3-1,归属下一条 mission)
|
||||||
|
|
||||||
|
下载设置页把「GitHub 加速前缀」的三态画成了三行(`GitHub 加速前缀 / 留空 = 跟随内置默认`、
|
||||||
|
`关掉它 / 填 off = 关闭`、`换掉它 / 填 URL 前缀 = 覆盖`)。这是**已知的观感问题**:
|
||||||
|
①`关掉它` / `换掉它` 读起来像按钮而不是取值说明;②同一个设置画成三行比三态本身更难懂。
|
||||||
|
|
||||||
|
不改的理由不是不重要,而是渲染器不支持「一行 + 副说明」:`pages/settings/frame.rs` 的
|
||||||
|
`setting_row` 只画「左标签 + 右单行值」,没有副说明位;要改得动 `frame.rs`(M196 正在改它)。
|
||||||
|
正确的家是**下一条「iced 设置数据通路」mission**——那三行会变成真控件,形状问题自然消失。
|
||||||
|
(同一条 finding 里还记着 presenter 侧描述里的 `1~64` / 出厂值:presenter 已改成用常量 `format!`,
|
||||||
|
iced 侧的 `rows` 是 `&'static str`,仍是**已知的第二真相源**,等真控件一并解决。)
|
||||||
|
|
||||||
|
### 6.1.1 三处「数字的第二来源」现状(P3-2 登记)
|
||||||
|
|
||||||
|
| 位置 | 能不能引用常量 | 守卫 |
|
||||||
|
|---|---|---|
|
||||||
|
| `presenter/src/host/settings/download.rs` 的行描述 | 能(`format!`) | 已改 |
|
||||||
|
| `app-iced/src/pages/settings/download.rs` 的 `rows` | 不能:`&'static [(&'static str, &'static str)]` 是编译期字面量,要改运行时生成得动 `Group` 的类型 | 无(`the_downloads_rows_state_semantics…` 只断言 `"1~64"` 这个字符串在,改常量不会红) |
|
||||||
|
| `setting/keys.rs` 的 `SettingSpec.doc` | 不能:`doc: &'static str`(`spec.rs:183`),`concat!` 只吃字面量、插不进常量 | ✅ `the_key_docs_quote_the_real_limits`(`config.rs`):把「1~64」「出厂 8」「出厂 3」与常量比对,改常量不改文案就红 |
|
||||||
|
|
||||||
|
`SettingSpec.doc` 那一处**不是注释**:`catalog.rs` 会把它当 JSON schema 的 `description` 发出去
|
||||||
|
(AI 工具与文档可见),所以它必须有守卫——这也是三处里唯一补上了守卫的一处。
|
||||||
|
|
||||||
|
## 7. 没验的部分
|
||||||
|
|
||||||
|
* **真实网络下的并发/重试行为未验**:守卫读的是 `Semaphore` 的真实许可数与 `attempts_for()` 的
|
||||||
|
配置读点;「2 条真连接同时下载时服务器只看到 2 条」和「某个源真的被试 3 次」没有在真网络下跑过
|
||||||
|
(离线回环能验连接复用,验不了限流的端到端时序)。
|
||||||
|
* **macOS / Linux 未验**:本轮只在 Windows 上编译与跑测试。
|
||||||
|
* **`download.gh_proxy` 的端到端未验**:那个键由 M197 登记,尚未合入 dev;`setting::document` 只认
|
||||||
|
已登记的键,所以「写文件 ⇒ 读出来 ⇒ 进候选链」这一段在 dev 上跑不通(core 侧「调优 → 候选链」
|
||||||
|
已守)。M197 合入后应补一条宿主侧端到端用例。
|
||||||
|
* **iced 设置页仍然不是可改控件**:它画的是语义行(默认值/区间/三态),真值不显示——因为 iced 侧
|
||||||
|
还没有「presenter 设置快照 → app → `Groups.download`」的数据通路(另立 mission,见 finding)。
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
Reference in new issue
Block a user