feat(m193): 下载并发/重试/gh-proxy 真接进引擎(schema 与引擎同源常量)

- download.concurrency / download.retry_limit 以前引擎侧零消费者(恒用
  EngineConfig::default() 的 64,而 schema 说 8)。现在默认值与硬上限收成
  download/config.rs 的常量,schema、EngineConfig::default()、Semaphore 容量
  三处同源;宿主把三个键翻译成 DownloadTuning 后经 shared::set_tuning(按数据根)
  灌进装配点,改了设置下一次取引擎就生效。
- download.gh_proxy 三态(留空=跟随内置默认 / off=关 / 前缀=覆盖)下沉到
  shared::build,所有走共享引擎的下载路径(内容库/版本清单/字体/安装)一致。
- 删掉 MirrorTable::set_gh_proxy 与 default_gh_proxy 两个改了没用的入口。
- 下载设置页把三条假演示值换成说语义的中文行(含三态说明与 1~64 硬上限)。
- 守卫:探针读 Semaphore 真实许可数(不是读配置),每条配变异证明。
- docs/download-settings.md 记下取证表(另四个键仍零消费,已立 finding)。
This commit is contained in:
worker-dlmsettings authored and AskaEth committed 2026-11-09 05:24:11 +08:00
1 parent 98262d3835
commit e8544b19a7
15 files changed
+958 -200

No files matched your search

+358 -4
View File
@@ -1,17 +1,51 @@
//! 引擎配置:设置层(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 super::backoff::BackoffPolicy;
use super::chunked::ChunkedConfig;
use super::region::RegionProfile;
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)]
pub struct EngineConfig {
/// 全局并发许可数(tokio `Semaphore`),默认 64。
/// 全局并发许可数(tokio `Semaphore`),默认 [`DEFAULT_MAX_CONCURRENCY`]。
pub max_concurrency: usize,
/// 每个源的尝试次数上限,默认 3。
/// 每个源的尝试次数上限,默认 [`DEFAULT_MAX_ATTEMPTS_PER_SOURCE`]。
pub max_attempts_per_source: u32,
/// 连接超时,默认 10s。
pub connect_timeout: Duration,
@@ -32,8 +66,8 @@ pub struct EngineConfig {
impl Default for EngineConfig {
fn default() -> Self {
Self {
max_concurrency: 64,
max_attempts_per_source: 3,
max_concurrency: DEFAULT_MAX_CONCURRENCY,
max_attempts_per_source: DEFAULT_MAX_ATTEMPTS_PER_SOURCE,
connect_timeout: Duration::from_secs(10),
read_timeout: Duration::from_secs(30),
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`):键不存在
/// → 出厂默认;落在区间内 → 原值;0 / 负数 / 超上限 → 出厂默认(**不夹到边界**:那是「想 9999
/// 给 64」,界面仍会说谎)。
#[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),
max_attempts_per_source: attempts
.and_then(|raw| u32::try_from(raw).ok())
.filter(|raw| (1..=MAX_ATTEMPTS_PER_SOURCE).contains(raw))
.unwrap_or(default.max_attempts_per_source),
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)),
}
}
}
/// 每个缓存根一份「宿主想要什么」(`engine_for` 构造时用它,见 [`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()` 的 `max_concurrency` 写回字面量 64
/// ⇒ 本用例红在 `max_concurrency`;把 `setting/keys.rs` 的 `download.concurrency` 默认值写回
/// 字面量 8 而常量改成 16 ⇒ 红在 schema 一侧。这条防的正是「两处各写一个数」长回来
/// (8 vs 64 就是这么长出来的)。
#[test]
fn the_schema_defaults_are_the_engine_defaults() {
let schema = launcher_schema();
assert_eq!(schema.version(), launcher_version());
let cfg = EngineConfig::default();
let tune = DownloadTuning::default();
// 键名常量与 schema 登记是同一份(宿主按常量读,schema 按常量登记)。
assert!(schema.spec(KEY_CONCURRENCY).is_some(), "{KEY_CONCURRENCY}");
assert!(schema.spec(KEY_RETRY_LIMIT).is_some(), "{KEY_RETRY_LIMIT}");
let concurrency = schema.spec(KEY_CONCURRENCY).expect("键已登记");
assert_eq!(
concurrency.default,
serde_json::json!(DEFAULT_MAX_CONCURRENCY)
);
assert_eq!(cfg.max_concurrency, DEFAULT_MAX_CONCURRENCY);
assert_eq!(tune.max_concurrency, DEFAULT_MAX_CONCURRENCY);
let retry = schema.spec(KEY_RETRY_LIMIT).expect("键已登记");
assert_eq!(
retry.default,
serde_json::json!(DEFAULT_MAX_ATTEMPTS_PER_SOURCE)
);
assert_eq!(cfg.max_attempts_per_source, DEFAULT_MAX_ATTEMPTS_PER_SOURCE);
assert_eq!(
tune.max_attempts_per_source,
DEFAULT_MAX_ATTEMPTS_PER_SOURCE
);
}
/// 硬上限:schema 的上界与引擎的夹取上限是同一个常量(防「schema 收 256、引擎不知情」)。
/// 变异证明:只改 `MAX_CONCURRENCY` 而 schema 仍写 64 ⇒ 红。
#[test]
fn the_hard_caps_are_single_sourced() {
let schema = launcher_schema();
let concurrency = i64::try_from(MAX_CONCURRENCY).expect("装得下");
let attempts = i64::from(MAX_ATTEMPTS_PER_SOURCE);
for (key, max) in [(KEY_CONCURRENCY, concurrency), (KEY_RETRY_LIMIT, attempts)] {
let spec = schema.spec(key).expect("键已登记");
assert_eq!(
spec.kind,
crate::setting::spec::ValueKind::Int { min: 1, max }
);
}
}
/// 未填 / 非法值回落默认,不阻断(任务书的边界条款)。
///
/// 变异证明:把 `filter(|raw| (1..=MAX_CONCURRENCY).contains(raw))` 删掉 ⇒ `9999` 与 `0`
/// 两处断言红;把 `unwrap_or(default.max_concurrency)` 改成 `unwrap_or(1)` ⇒ `None` 断言红。
#[test]
fn missing_or_illegal_values_fall_back_to_the_defaults() {
let tune = DownloadTuning::from_settings(None, None, None);
assert_eq!(tune, 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), Some(bad), None);
assert_eq!(
tune,
DownloadTuning::default(),
"{bad} 越界时应回落默认而不是夹到边界"
);
}
let tune = DownloadTuning::from_settings(Some(2), Some(1), None);
assert_eq!(tune.max_concurrency, 2);
assert_eq!(tune.max_attempts_per_source, 1);
}
/// 三态:**空串 ≠ 关**(这条是「设置项说假话」的高发点,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` 的三档真的改到档案上、并且**改到候选链上**(`MirrorTable::expand()` 读的就是
/// 档案里那个前缀,内容库/版本清单都走它)。这是「改键 ⇒ 候选链变」的 core 级一半。
/// 变异证明:把 `Off` 分支改成原样返回 `profile` ⇒ `off` 那条红。
#[test]
fn gh_proxy_override_reaches_the_region_profile_and_the_candidate_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 chain = |profile: &RegionProfile| -> Vec<String> {
table
.expand(url, ResourceKind::Other, profile)
.into_iter()
.map(|candidate| candidate.url)
.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!(
chain(&kept).iter().any(|url| url.contains("gh-proxy")),
"出厂默认会套前缀"
);
let off = DownloadTuning::from_settings(None, None, Some("off"))
.apply_gh_proxy(mirror_first.clone());
assert!(!off.gh_proxy_enabled(), "off 必须关掉 gh-proxy");
assert_eq!(off.gh_proxy, None);
assert!(
!chain(&off).iter().any(|url| url.contains("gh-proxy")),
"关了就不该有候选"
);
assert!(
table
.expand(url, ResourceKind::Other, &off)
.iter()
.all(|candidate| candidate.source != SOURCE_GH_PROXY),
"连 source 也不该出现"
);
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());
assert!(
chain(&prefix)
.iter()
.any(|url| url.starts_with("https://gh.example/")),
"覆盖前缀必须进候选链"
);
}
/// 每个数据根的调优互不串味(一个根改成 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());
}
}
+21 -20
View File
@@ -5,18 +5,15 @@
//! ```text
//! 校验请求 → 内容寻址缓存直取(零网络)
//! → 候选规划(镜像表展开 + 健康度排序)
//! → Semaphore 限流
//! → Semaphore 限流 ← 容量来自设置(M193,见 config.rs)
//! → 候选源外循环(换源),每源内循环(指数退避 + 抖动)
//! → 续传判定(保守)→ 单连接 / 多连接分块 → 流式校验 → 原子落盘
//! → 失败则保留中间文件供下次续传
//! ```
//!
//! 最后一步保留的是**本会话命名的**中间文件(`<name>.ullpart-<session>-<seq>`,不再固定
//! 叫 `<name>.ullpart`):下次(甚至下次启动、重启后再下同一个文件)由 `PartFile::for_dest`
//! 在目标目录里认领它并改名成新的会话名,续传判定照旧。详细身份规则见 `atomic.rs` 模块头的
//! 「中间文件的身份」。
//!
//! 具体传输细节在 `transfer.rs`,错误分类在 `http.rs`,本文件只负责顺序与策略。
//! 中间文件是本会话命名的(`<name>.ullpart-<session>-<seq>`),下次(甚至下次启动)由
//! `PartFile::for_dest` 认领并改名,续传判定照旧(身份规则见 `atomic.rs` 模块头)。传输细节在
//! `transfer.rs`,错误分类在 `http.rs`,本文件只负责顺序与策略。
use std::path::Path;
use std::sync::Arc;
@@ -27,7 +24,7 @@ use tokio::sync::Semaphore;
use super::atomic::PartFile;
use super::backoff::Jitter;
use super::cache::{now_ms, ContentCache};
use super::config::EngineConfig;
use super::config::{EngineConfig, MAX_CONCURRENCY};
use super::error::{DownloadError, DownloadErrorKind};
use super::health::HealthRegistry;
use super::http;
@@ -72,8 +69,10 @@ impl DownloadEngine {
cache: Arc<ContentCache>,
) -> Self {
let http = http::build_client(&cfg).unwrap_or_else(|_| reqwest::Client::new());
let sem = Arc::new(Semaphore::new(cfg.max_concurrency.max(1)));
let seed = now_ms() ^ (u64::from(cfg.max_concurrency as u32) << 32);
// 硬上限夹取(M193):手改的 launcher.json / CLI 自拼的 cfg 都可能写着 9999,在唯一构造点再夹一次。
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`)。
super::shared::record_build(cache.root());
Self {
@@ -102,10 +101,15 @@ impl DownloadEngine {
&self.cfg
}
/// HTTP 客户端(同一条连接池与超时配置)。
///
/// 给「一锤子查询」用——例如 Adoptium 的版本查询(`core::game::java::api`):
/// 没必要为了一个 GET 再造一个 client,代理/超时/证书栈都在这里统一。
/// **真实并发闸门**此刻的许可数(探针,M193):读 [`Self::acquire`] 会去抢的那张
/// `Semaphore`,**不是** `config().max_concurrency`。有下载在飞时偏小,空闲等于容量。
#[must_use]
pub fn concurrency_probe(&self) -> usize {
self.sem.available_permits()
}
/// HTTP 客户端(同一条连接池与超时配置):给「一锤子查询」用(如 Adoptium 版本查询),
/// 没必要为了一个 GET 再造一个 client。
#[must_use]
pub fn client(&self) -> &reqwest::Client {
&self.http
@@ -243,7 +247,7 @@ impl DownloadEngine {
}
Err(err) => {
// 4xx(NotFound)不算「源坏」:院校镜像只留最新补丁,请求旧补丁必然 404,
// 记成失败会把唯一可用镜像冷却掉(M33/M34 实测:TUNA 每个目录只保 1 个归档)。
// 记成失败会把唯一可用镜像冷却掉(M33/M34 实测:TUNA 每目录只保 1 个归档)。
if err.kind == DownloadErrorKind::NotFound {
self.health.record_missing(
&candidate.source,
@@ -346,11 +350,8 @@ impl DownloadEngine {
})
}
/// 该源此刻给几次尝试:
///
/// * 冷却中 → 1 次(这次就是「恢复探测」,失败会重新冷却);
/// * 刚出冷却、还没洗白 → 也只给 1 次(`is_probe()`,别让死源每次到期都吃满 3 次);
/// * 正常 → 配置的 `max_attempts_per_source`。
/// 该源此刻给几次尝试:冷却中 / 刚出冷却未洗白 → 1 次(那就是恢复探测,别让死源每次到期
/// 都吃满预算);正常 → 设置里的 `download.retry_limit`(= `max_attempts_per_source`)。
pub(crate) fn attempts_for(&self, source: &str, kind: ResourceKind, now_ms: u64) -> u32 {
let health = self.health.health_of(source, kind);
if health.is_cooling_down(now_ms) || health.is_probe(now_ms) {
+3 -34
View File
@@ -175,49 +175,18 @@ impl MirrorTable {
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]
pub fn prefix_rule_for(&self, url: &str, kind: ResourceKind) -> Option<&PrefixRule> {
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 前缀。
///
/// **生效前缀的唯一真相源是 `region.gh_proxy`**(设置层/地区判定写入,`None` = 用户关闭)。
/// 表格里的 `gh_proxies` 只是「已知可用前缀」清单,配合 `default_gh_proxy()` 给设置做默认值。
/// 表格里的 `gh_proxies` 只是「已知可用前缀」清单(诊断页可读),**改它不会生效**——M193
/// 删掉了原先那个 `MirrorTable::set_gh_proxy`,正是因为它改了这张表而 `expand()` 读的是档案,
/// 属于「看起来能改、其实没人读」的入口。
#[must_use]
pub fn gh_proxy_candidates(&self, url: &str, region: &RegionProfile) -> Vec<Candidate> {
if region.mode != RegionMode::MirrorFirst {
+106 -18
View File
@@ -32,23 +32,23 @@
//! 只 forget 的话它会一直用旧实例(含旧地区档案)直到进程结束。所以「重新检测地区」那条
//! 设置动作除了 [`forget`] 还调 `CoreHost::refresh_update_factory()` 把新引擎接过去
//! (M165 复审 P2-1:这是本模块文档原先说漏的一半)。
//! * **并发多少**:`EngineConfig::default().max_concurrency`(64)现在是**进程级**上限,
//! 不再是「每请求 64」。单请求独占时与从前一致;多请求并发时它们分摊这 64 张许可。
//! * **并发多少**(M193):容量来自设置 `download.concurrency`(出厂 8、硬上限 64,见 [`set_tuning`]),
//! 而且是**进程级**上限——多请求并发时分摊这批许可,不是「每请求 8」。
//! * **镜像切换 / 失效**:一律走引擎原有的候选规划 + 健康度冷却,本模块不参与判定;唯一可见的
//! 差别是健康度记忆现在真的跨请求累积(这本就是它存在的理由)。
//!
//! # 与 [`super::blocking::block_on`] 的绑定关系
//!
//! `reqwest` 的连接池绑在**驱动连接的运行时**上:引擎复用了而每次 `block_on` 都新起一个运行时,
//! 池里的空闲连接就属于已经销毁的运行时,复用会退化成「拿到的都是死连接」。所以引擎复用必须
//! 配运行时常驻(`blocking` 模块那头一并改成进程内唯一运行时)。两者是同一件事的两半。
//! 池里的空闲连接就属于已销毁的运行时,复用会退化成「拿到的都是死连接」。所以引擎复用必须配
//! 运行时常驻(`blocking` 那一头一并改成进程内唯一运行时)。两者是同一件事的两半。
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use super::cache::{now_ms, ContentCache};
use super::config::EngineConfig;
use super::config::{publish, wanted, DownloadTuning, EngineConfig};
use super::engine::DownloadEngine;
use super::health::HealthRegistry;
use super::mirrors::MirrorTable;
@@ -102,9 +102,8 @@ pub fn engine_for(cache_root: impl AsRef<Path>) -> Arc<DownloadEngine> {
/// 让该数据根的共享引擎失效(下次 [`engine_for`] 重新构造)。
///
/// **只让登记处不再持有那一份**:已经拿到 `Arc` 的人继续用旧的。短命请求自然结束;长命持有者
/// (presenter 的自更新 `Factory`)必须自己重建,否则它会一直用旧实例——本仓在「重新检测地区」
/// 的设置动作里调 `CoreHost::refresh_update_factory()` 补上这一步(见模块文档「`forget` 到什么范围」)。
/// 返回是否真淘汰了一份。
/// (presenter 的自更新 `Factory`)必须自己重建——「重新检测地区」那条设置动作就补调了
/// `CoreHost::refresh_update_factory()`。返回是否真淘汰了一份。
pub fn forget(cache_root: impl AsRef<Path>) -> bool {
registry()
.lock()
@@ -116,9 +115,7 @@ pub fn forget(cache_root: impl AsRef<Path>) -> bool {
/// 记一笔「这个缓存根上又造了一个引擎」。
///
/// **唯一调用点是 [`DownloadEngine::new`]**(不是 [`engine_for`]):记账卡在构造这个卡口上,
/// 于是 [`constructions`] 统计的是**进程里真实发生过几次构造**,与「谁造的」无关。
/// 若只在 `engine_for` 里记账,任何绕过登记处、自己 `DownloadEngine::new` 的调用点都统计不到
/// (M165 复审备注 2 的诉求)。
/// [`constructions`] 才是「进程里真实构造过几次」,与谁造的无关(M165 复审备注 2)。
pub(crate) fn record_build(cache_root: &Path) {
*ledger()
.lock()
@@ -127,10 +124,8 @@ pub(crate) fn record_build(cache_root: &Path) {
.or_default() += 1;
}
/// 该数据根在本进程里**实际构造过几次引擎**(不论谁构造的)。
///
/// 这是给守卫与诊断用的:跨请求复用成立时,同一个根无论被请求多少次,这个数都停在 `1`
/// (`forget` 之后每重建一次 +1)。只断言「结果一样」测不出复用——引擎建几次都能下同一个文件。
/// 该数据根在本进程里**实际构造过几次引擎**(不论谁构造的):复用成立时停在 `1`(`forget`
/// 之后每重建一次 +1)。只断言「结果一样」测不出复用——引擎建几次都能下同一个文件。
#[must_use]
pub fn constructions(cache_root: impl AsRef<Path>) -> usize {
ledger()
@@ -141,9 +136,14 @@ pub fn constructions(cache_root: impl AsRef<Path>) -> usize {
.unwrap_or(0)
}
/// 真实构造(**唯一**一处 `DownloadEngine::new` 的宿主侧装配)。
/// 真实构造(**唯一**一处 `DownloadEngine::new` 的宿主侧装配)。装配参数从该数据根的
/// [`wanted`] 取,不用裸 `EngineConfig::default()`(M193):并发 → `Semaphore` 容量、尝试次数 →
/// `attempts_for()` 上限、gh_proxy → 地区档案(`MirrorTable::expand()` 只认档案里那个前缀,所以
/// 内容库 / 版本清单 / 字体 / 安装**所有**路径一起生效)。值由宿主翻译后经 [`set_tuning`] 灌入——
/// core 不认识设置键名,也不依赖 `core::setting`。
fn build(cache_root: &Path) -> DownloadEngine {
let now = now_ms();
let tune = wanted(cache_root);
let region_path = cache_root.join("region.json");
let region = load_cached(&region_path, now).unwrap_or_else(|| {
let profile = decide_from(&detect_signals(), now);
@@ -151,14 +151,30 @@ fn build(cache_root: &Path) -> DownloadEngine {
profile
});
DownloadEngine::new(
EngineConfig::default(),
tune.apply_to(EngineConfig::default()),
Arc::new(MirrorTable::builtin()),
Arc::new(region),
Arc::new(tune.apply_gh_proxy(region)),
Arc::new(HealthRegistry::open_in(cache_root, now)),
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)]
mod tests {
use super::*;
@@ -308,4 +324,76 @@ mod tests {
assert_eq!(conns, 1, "两次下载应复用同一条连接");
std::fs::remove_dir_all(&dir).ok();
}
/// **守门①②**:设置说 2 ⇒ 引擎**真实的并发闸门**就是 2(探针读 `Semaphore`,不是读配置);
/// 同一口装配点上的 gh-proxy 三档也真的改变 `plan()` 的候选链。
///
/// 变异证明(只改实现侧):① `build` 的 `tune.apply_to(...)` 换回裸 `EngineConfig::default()`
/// ⇒ 探针 8,红;② `tune.apply_gh_proxy(region)` 换回 `region` ⇒ `off` 档仍有候选,红;
/// ③ 删掉 `set_tuning` 里那条 `remove` ⇒ 换参数后交回旧引擎(探针 2 ≠ 5),红。
/// 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::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 张许可");
assert_eq!(
engine.config().max_attempts_per_source,
1,
"尝试次数同一条路"
);
let off = chain(&dir);
assert!(!off.iter().any(|url| url.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(|url| url.starts_with("https://gh.example/")),
"覆盖前缀必须出现在候选链里:{over:?}"
);
// 硬上限(纵深防御):宿主绕开 `from_settings` 直接塞 9999,闸门也只能开到 64。
let wild = DownloadTuning {
max_concurrency: 9_999,
..DownloadTuning::default()
};
set_tuning(&dir, wild);
assert_eq!(
engine_for(&dir).concurrency_probe(),
super::super::config::MAX_CONCURRENCY,
"9999 张许可不能真开"
);
std::fs::remove_dir_all(&dir).ok();
}
}
+18 -18
View File
@@ -5,6 +5,9 @@ use super::catalog::SettingSchema;
use super::schema::{SchemaId, SchemaVersion};
use super::spec::{AiPolicy, SettingSpec};
// M193:下载两键的默认值与硬上限的唯一来源在 `download::config`(引擎侧),这里只登记成 schema。
use crate::download::config as download;
/// `core.data_mode` 允许值。
pub const DATA_MODES: &[&str] = &["portable", "standard", "custom"];
/// `download.region` 允许值。
@@ -45,23 +48,18 @@ pub mod update;
///
/// `1.1.0` 登记 D20 实例隔离键(`instances.mode` 等)。
/// `1.2.0` 登记 D23 的 JDK 落点与发行版优先级键(`java.installRoot` / `java.vendorPriority`):
/// 同样只推 minor、不强制迁移——[`super::document`] 的「默认值不落盘」让老文件缺键即取默认,
/// 迁移链(按 major 走)不需要新步骤;`1.1.0 → 1.2.0` 的差异只体现在文件里出现新键之后。
/// 只推 minor、不强制迁移——[`super::document`] 的「默认值不落盘」让老文件缺键即取默认。
/// `1.3.0` 登记 D17 的「外观(高级)」P1 键(`appearance.bg_image*` / `panel_alpha` / `glass` /
/// `accent_color`):同一套纯增量语义——出厂全默认(高级特性全关)即等于老文件缺键。
/// `1.4.0` 登记 P4 的外观下层三键(`window_opacity` / `titlebar_mode` / `palette_source`,M91):
/// 仍然只推 minor、不加迁移步骤——三者的出厂值正是「老文件没有这些键」时的等价物。
/// `1.5.0` 登记 M116 的窗口圆角策略(`appearance.window_corner`,出厂 `system`):纯增量。
/// `1.6.0` 登记 M161 的「自更新」两键(见 [`update`]):纯增量但带一步数据迁移
/// (此前住在 `cache/update/prefs.json`,迁移见 [`super::update_prefs`])。
/// `accent_color`)与 `1.5.0` 的窗口圆角、`1.4.0` 的外观下层三键(M91):都是纯增量——
/// 出厂全默认即等于老文件缺键。
/// `1.6.0` 登记 M161 的「自更新」两键(见 [`update`]):纯增量 + 一步迁移(见 [`super::update_prefs`])。
/// `1.7.0` 登记 M168 的中文字体下载地址覆盖键(`ui.font_cdn`,见 [`font`]):纯增量、无迁移。
///
/// **旧读新(方向相反,措辞必须精确)**:1.4.0 的客户端读 1.5.0 的文件时,
/// [`super::schema::SchemaCompat::check_url`] 走 `found.minor > expected.minor` →
/// `ReadOnlyPreserveSchema` → [`super::schema::SchemaCompat::access`] → `FileAccess::ReadOnly`。
/// 也就是说**整份配置对它只读**(值照读、`store.set` 一律 `SettingError::ReadOnly`、`save` 跳过),
/// 不是「只是少一档」。这是 minor 提升的既定代价——也正是新键必须推 minor 而不是偷偷塞进旧版本的
/// 原因:回退到旧版本会出现「设置改不动」,而原因只在 `$schema` 那一行里,不看版本号无从排查。
/// `ReadOnlyPreserveSchema` → `FileAccess::ReadOnly`:**整份配置对它只读**(值照读、`store.set`
/// 一律 `SettingError::ReadOnly`),不是「只是少一档」。这正是新键必须推 minor 的原因——
/// 回退到旧版本会出现「设置改不动」,而原因只在 `$schema` 那一行里。
#[must_use]
pub fn launcher_version() -> SchemaVersion {
SchemaVersion::new(1, 7, 0)
@@ -102,19 +100,21 @@ pub fn launcher_schema() -> SettingSchema {
AiPolicy::sensitive(),
"实例默认隔离模式:isolated(运行目录派生 versions/<版本 id>,D20 默认)/ shared(所有实例共用 .minecraft 根)",
))
// M193:默认值/区间只有一个来源——`download::config` 的常量。此前这里写 8、
// `EngineConfig::default()` 写 64,界面说「当前 8」引擎却跑 64,调并发完全无效。
.with(SettingSpec::int(
"download.concurrency",
8,
(1, 256),
download::DEFAULT_MAX_CONCURRENCY as i64,
(1, download::MAX_CONCURRENCY as i64),
AiPolicy::silent(),
"下载并发数",
"同时下载的文件数(1~64,出厂 8)。这是引擎真实持有的并发许可,不是展示值;越界或手改坏值按出厂值生效",
))
.with(SettingSpec::int(
"download.retry_limit",
3,
(0, 10),
download::DEFAULT_MAX_ATTEMPTS_PER_SOURCE as i64,
(1, download::MAX_ATTEMPTS_PER_SOURCE as i64),
AiPolicy::silent(),
"单个文件重试次数",
"单个下载源的最多尝试次数(含首次,1 = 不重试;出厂 3)。换源不受此限:候选链上有几个源就各给几次",
))
.with(SettingSpec::enumeration(
"download.region",