fix(m168): 复审 r1 五项 —— 修红的 integration target、独立失败域、整体时限

复审 r1(p1-1/fix-then-merge)五项全改:

1. **p1-1 CI 会红的 4 条 integration test**(`crates/core/tests/setting/`,我上一轮只跑了 `--lib`,
   scope 漏掉了覆盖 schema 版本号的目录式 target):
   - `java_install_keys` / `isolation_keys` / `appearance_keys`:版本断言 1.6.0 → 1.7.0(M168 的
     `ui.font_cdn`),并写明「每加一批键推一次 minor 是纪律」。
   - `update_keys::a_read_only_launcher_config_never_retires_the_legacy_file`:夹具从**写死
     1.7.0** 改成 `launcher_version().minor + 1` 经 `SchemaRef::url()` 生成。写死数字的版本一推
     这条守卫就变成「同版本」⇒ 文件可写 ⇒ 迁移真的执行 ⇒ 旧文件被退役,而断言还绿着(用户数据
     守卫静默失效)。相对推导不会过期。
2. **p2-1 备用镜像不备用**:`cdn` 与 `fastly` 同属 Fastly(都 CNAME 到 jsdelivr.map.fastly.net),
   换掉。现在首选 `gcore.jsdelivr.net`(Cloudflare,独立失败域),二选 `cdn.jsdelivr.net`
   (Fastly)。注释里放了实测表(本机 + 复审机两份数据、字节数、sha256、IPv6 慢路径),
   并写清排序依据(Fastly 侧 IPv6 实测 67 KB/s ⇒ 丢掉 4 分钟;Cloudflare 侧两次都完整下完)。
3. **p2-2 整体时限**:`core::download::font` 新增 `ATTEMPT_DEADLINE = 90 s` /
   `TOTAL_DEADLINE = 180 s`,落在本模块自己的候选循环里(不动引擎全体语义):每次尝试
   `min(单次上限, 剩余总预算)`,超时归 `Network` 档。新增守卫
   `a_trickling_connection_is_cut_by_the_deadline`(回环服务端每 100 ms 挤 1 字节、预算 300 ms)。
   顺带修掉一个真 bug:`tokio::time::timeout` 在运行时外构造会 panic(5 条既有用例当场抓到),
   现在整段包进 `block_on(async { … })`。UI 文案改成「网络不通或太慢(超时)」,不校验失败混。
4. **p2-3 说 `Source::PendingPin` 的两处**:`fonts/cjk.rs` 的模块文档已改;`ui/lang.rs::log_cjk`
   那句「D28 的下载通道还是 PendingPin」(在下载已发起之后打印,等于当面否定本功能)改成
   「已按 D28 发起下载,见 [font] cjk-download … 与设置页那一行」。全仓再扫,只剩注释里的历史说明。
5. **p2-4 假守卫**:判据抽成纯函数 `install::plan(strategy, current) -> Step`(`Skip` / `Apply` /
   `Start`),`after_probe` 只执行。旧的空转用例换成打真判据的打表用例;变异证明:删掉
   `Skip` 那一臂 ⇒ `a_started_or_finished_install_is_not_restarted` 红(已实跑)。

次要项也一并处理:`from_cache` 改成**本次是否真的命中缓存**(`CdnFetcher::was_cached()`,
`has_cached()` 降级为诊断字段 `cache_present=`);`FontSpec` 的 URL 口径收紧为「非回环必须
https」(`is_acceptable_url` + 守卫);`ui.font_cdn` 写进状态行 hint;文档里的 17.0 MB → 16.9 MB。
`download/font.rs` 拆出 `font/spec.rs` 守 400 行。

验证(这台机器上单个 800 MB 测试 exe 链接要 10 分钟,链接期间文件被 Defender 扫描,故耗时很长):
- `cargo test -p unlucky-core --all-targets` → **1188 passed / 0 failed**(16 个 target,含
  `setting` 那个 115 条、上一轮红 4 条的 integration target)
- `cargo test -p unlucky-i18n` → 31 passed / 0 failed
- `cargo test -p unlucky-app-iced --lib` → 685 passed / 0 failed
- `cargo clippy -p unlucky-i18n -p unlucky-core -p unlucky-app-iced --all-targets -- -D warnings` → 干净
- `cargo fmt --all -- --check` → 干净;全仓受检文件行数最大 388(<400)
This commit is contained in:
AskaEth committed 2026-11-08 14:38:23 +08:00
1 parent 449ded2cb7
commit 875b792206
17 files changed
+512 -170

No files matched your search

+25 -6
View File
@@ -27,6 +27,7 @@
//! `ui.font_cdn` 允许换成国内镜像(整条 URL)。**哈希与字节数仍是内置那一份**:镜像是同一份内容
//! 的不同出口,给了别的内容就该被拒——宁可不下载,也不把没校验过的字节注册成字体。
use std::cell::Cell;
use std::path::PathBuf;
use unlucky_core::download::font::{self as core_font, FontFetchError, FontSpec};
@@ -39,6 +40,12 @@ use crate::platform::data;
pub struct CdnFetcher {
cache_root: PathBuf,
spec: FontSpec,
/// 最近一次 `fetch` 是否**真的**命中缓存(`None` = 还没取过)。
///
/// 为什么不在调用方「取之前看一眼文件在不在」:那个判据会说谎——缓存文件若已损坏,
/// `core::download::font` 会删掉重下(零网络不成立),而「取之前在不在」仍然是 true。
/// 复审 r1 次要项点名的就是这个:日志 `from_cache=true` 必须等于「这次没联网」。
last_from_cache: Cell<Option<bool>>,
}
impl CdnFetcher {
@@ -50,18 +57,28 @@ impl CdnFetcher {
Ok(Self {
cache_root,
spec: spec_of(font)?,
last_from_cache: Cell::new(None),
})
}
/// 缓存是不是已经在盘上(只 stat,不做校验)。
/// 缓存文件是不是已经在盘上(只 stat,不做校验)。
///
/// 用途只有两个:UI 那行「已就绪」的字样、日志里 `from_cache=true` 的标注。**它不承担守卫**:
/// 真正的命中判定在 `core::download::font`(读出来 + 复验魔数后才算命中)。
/// 用途只有**诊断**(`[font] cjk-download start … cache_present=`)。它**不是**命中判据:
/// 真正的命中判定在 `core::download::font`(读出来 + 复验哈希与魔数),而「这次有没有联网」
/// 只看 [`Self::was_cached`]——坏缓存会被删掉重下,那时这个函数仍然会返回 `true`。
#[must_use]
pub fn has_cached(&self) -> bool {
core_font::cache_path(&self.cache_root, &self.spec).is_file()
}
/// 最近一次 [`Fetcher::fetch`] 是否零网络命中缓存(`None` = 还没取过)。
///
/// 状态行与日志的 `from_cache` 只认这个值;`has_cached()` 只是「文件在不在」,两者不可互换。
#[must_use]
pub fn was_cached(&self) -> Option<bool> {
self.last_from_cache.get()
}
/// 缓存文件落点(诊断日志用)。
#[must_use]
pub fn cache_file(&self) -> PathBuf {
@@ -71,9 +88,10 @@ impl CdnFetcher {
impl unlucky_i18n::font::Fetcher for CdnFetcher {
fn fetch(&self, _font: &'static CjkFont) -> Result<Vec<u8>, FetchError> {
core_font::fetch_blocking(&self.cache_root, &self.spec)
.map(|fetch| fetch.bytes)
.map_err(|error| translate(error))
let outcome = core_font::fetch_blocking(&self.cache_root, &self.spec).map_err(translate);
self.last_from_cache
.set(outcome.as_ref().ok().map(|fetch| fetch.from_cache));
outcome.map(|fetch| fetch.bytes)
}
}
@@ -147,6 +165,7 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
let fetcher = CdnFetcher::new(&NOTO_SANS_SC, dir.clone()).expect("下载器");
assert!(!fetcher.has_cached());
assert_eq!(fetcher.was_cached(), None, "还没取过 ⇒ 不知道");
assert!(fetcher
.cache_file()
.ends_with("cjk-noto-sans-sc-a3041811a78c361b.ttf"));
+3 -2
View File
@@ -19,8 +19,9 @@
//!
//! - **抓得住**:语言 + 字体库族名 → 策略(含 `Unprobeable` 这一支);优先级顺序(注入的假源可测);
//! - **抓不住**:①族名存在 ≠ 字形覆盖(`SimSun` 也可能是个残缺子集)。真判据要开 `ttf-parser`
//! 查 cmap,本 mission 不引依赖(M60 Q3 spike 做过,结论见 `DevDocs`);
//! ②下载与注册(`Strategy::NeedsDownload` 只是信号,`Source::PendingPin` 还没钉 URL/哈希)。
//! 查 cmap,本 crate 不引依赖(M60 Q3 spike 做过);
//! ②`Strategy::NeedsDownload` 之后的下载与注册(M168 已实装,见 [`super::cdn`] /
//! [`super::install`]:URL 与 sha256 钉在 `unlucky_i18n::font::NOTO_SANS_SC`,本模块只到判据为止)。
//!
//! ## `ui.language` 的接线(M106)
//!
+46 -23
View File
@@ -233,36 +233,59 @@ pub fn ensure_for(
}
}
/// 判据结论 → 现在这一步的结局(生产入口)。
/// 判据结论 + **当前状态** → 这一步该做什么(纯函数,不碰全局状态、不起线程)。
///
/// 只有 `NeedsDownload` 且**当前不在下载/已就绪**时才真的发起下载:
/// 这是幂等闸的**唯一判据**:`after_probe` 只是它的执行者。之所以拎成纯函数,是因为上一版
/// 有一条「自称守卫、其实什么都没测」的用例(复审 r1 P2-4):它在循环里断言一个从没被传进任何
/// 函数的假下载器「没被调用」,恒绿。现在守卫直接打这张表——把 `Skip` 那一臂删掉,用例必红。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Step {
/// 什么都不做(已经在下载 / 已就绪)。
Skip,
/// 只把状态改成这个(零网络)。
Apply(CjkInstall),
/// 真的发起一次下载。
Start(&'static font::CjkFont),
}
/// 判据表(判据本体就在这张表的 `match` 里,`after_probe` 不含任何判据)。
///
/// * 英文环境 / 系统已有 → 只更新状态(零网络);
/// * 探测不出 → 只更新状态(零网络,**不**当成缺字体);
/// * 已经在下载 / 已就绪 → 什么都不做(幂等:切一次语言不会起第二条下载);
/// * 上次失败过 → **只有再次切语言才会重试**(用户显式动作),不会自己反复联网。
pub fn after_probe(strategy: Strategy) -> CjkInstall {
let current = status();
/// | 策略 | 当前状态 | 这一步 |
/// |---|---|---|
/// | `NotNeeded` / `System` / `Unprobeable` | 任意 | `Apply`(零网络) |
/// | `NeedsDownload` | 下载中 / 已就绪 | **`Skip`**(幂等:切一次语言不起第二条线程) |
/// | `NeedsDownload` | 未判定 / 失败过 / 其它 | `Start`(失败后的重试只能是用户再切一次语言) |
#[must_use]
pub fn plan(strategy: Strategy, current: &CjkInstall) -> Step {
match strategy {
Strategy::NotNeeded => {
update(CjkInstall::NotNeeded);
CjkInstall::NotNeeded
Strategy::NotNeeded => Step::Apply(CjkInstall::NotNeeded),
Strategy::System { family } => Step::Apply(CjkInstall::System {
family: family.to_owned(),
}),
Strategy::Unprobeable => Step::Apply(CjkInstall::Unprobeable),
Strategy::NeedsDownload(font) => {
if current.is_downloading() || matches!(current, CjkInstall::Ready { .. }) {
Step::Skip
} else {
Step::Start(font)
}
}
Strategy::System { family } => {
let state = CjkInstall::System {
family: family.to_owned(),
};
}
}
/// 判据结论 → 现在这一步的结局(生产入口;判据全在 [`plan`],这里只执行)。
///
/// * 英文环境 / 系统已有 / 探测不出 → 只更新状态(零网络);
/// * 已经在下载 / 已就绪 → 什么都不做(幂等:切一次语言不会起第二条下载、不会重复注册 17 MiB);
/// * 上次失败过 → 只有再次切语言才会重试(用户显式动作),不会自己反复联网。
pub fn after_probe(strategy: Strategy) -> CjkInstall {
match plan(strategy, &status()) {
Step::Skip => status(),
Step::Apply(state) => {
update(state.clone());
state
}
Strategy::Unprobeable => {
update(CjkInstall::Unprobeable);
CjkInstall::Unprobeable
}
Strategy::NeedsDownload(font) => {
if current.is_downloading() || matches!(current, CjkInstall::Ready { .. }) {
return current;
}
Step::Start(font) => {
worker::start(font);
status()
}
+67 -20
View File
@@ -38,11 +38,11 @@ impl Fetcher for FakeFetcher {
}
}
/// 假注册器收到的字节长度(只关心「注册了几次、每次多大」)。
type Registered = std::rc::Rc<RefCell<Vec<usize>>>;
/// 假注册器:记收到的字节数,返回一个族名。
fn recorder() -> (
std::rc::Rc<RefCell<Vec<usize>>>,
impl Fn(Vec<u8>) -> Result<String, String>,
) {
fn recorder() -> (Registered, impl Fn(Vec<u8>) -> Result<String, String>) {
let seen = std::rc::Rc::new(RefCell::new(Vec::new()));
let recorder = std::rc::Rc::clone(&seen);
let register = move |bytes: Vec<u8>| {
@@ -156,26 +156,73 @@ fn every_failure_class_survives_the_trip() {
));
}
/// 状态机的**幂等**:已就绪 / 下载中时再问一次不会起第二条下载。
/// 幂等闸(M168 复审 P2-4:这条以前是**假守卫**,现在打真判据)。
///
/// 抓得住:`after_probe` 里删掉 `is_downloading() || Ready` 那半(用户来回切语言会起 N 条线程,
/// 每条都下一遍 17 MiB)。本用例只测纯判据那一层——`after_probe` 的线程行为要真网络,没测。
/// 复审的原话:上一版的循环体只断言「一个从没被传进任何函数的假下载器没被调用」,把
/// `after_probe` 里的 `is_downloading() || Ready` 整段删掉它**依然全绿**。
///
/// 现在直接打 [`plan`]:删掉 `Skip` 那一臂(或把它改成 `Start`),本用例在
/// `assert_eq!(plan(...), Step::Skip)` 处当场红。为什么这条闸不是可有可无:去掉之后每切一次
/// 语言就 spawn 一条线程,线程会命中缓存(不联网)但会**再注册一遍 17 MiB 字体**
/// (`load_font(Cow::Owned)` 不做去重),内存随切换次数累积。
///
/// 抓得住:幂等判据被删/被改反(下载中、已就绪两种状态各自一行)。
/// 抓不住:`after_probe` 真的 spawn 几条线程(那要真线程 + 真网络,见报告「未验」)。
#[test]
fn a_started_or_finished_install_is_not_restarted() {
// 直接打状态机的输入输出:Ready 与 Downloading 都不会进 fetcher。
let (_, register) = recorder();
let idle = FakeFetcher::new(Ok(vec![1]));
for state in [
CjkInstall::Ready {
family: "Noto Sans SC".to_owned(),
from_cache: true,
},
CjkInstall::Downloading { bytes: 17_772_300 },
] {
assert!(!matches!(state, CjkInstall::Unknown), "{state:?}");
assert_eq!(idle.calls(), 0);
let _ = &register;
let downloading = CjkInstall::Downloading { bytes: 17_772_300 };
let ready = CjkInstall::Ready {
family: "Noto Sans SC".to_owned(),
from_cache: true,
};
for current in [downloading, ready] {
assert_eq!(
plan(Strategy::NeedsDownload(&NOTO_SANS_SC), &current),
Step::Skip,
"{current:?}:已经在下载 / 已就绪时再问一次,必须什么都不做"
);
}
// 反方向(否则上面两条可能是「永远 Skip」的假绿):其余三种当前状态都要真的发起。
for current in [
CjkInstall::Unknown,
CjkInstall::NotNeeded,
CjkInstall::Failed {
class: FetchClass::Network,
message: "超时".to_owned(),
},
] {
assert_eq!(
plan(Strategy::NeedsDownload(&NOTO_SANS_SC), &current),
Step::Start(&NOTO_SANS_SC),
"{current:?}:需要下载且还没成就该发起(失败后的重试也只能走这条)"
);
}
}
/// 三种「不需要下载」的策略在 [`plan`] 里只产生 `Apply`(**零网络**),且状态文案对得上。
///
/// 抓得住:谁把 `NotNeeded` / `System` / `Unprobeable` 的臂改成 `Start`(那就会替英文用户偷偷
/// 下一次 17 MiB);也抓得住「系统已有字体」被记成「需要下载」这种状态映射错。
/// 抓不住:真线程行为(`after_probe` 的执行部分)。
#[test]
fn the_idle_strategies_only_apply_state() {
assert_eq!(
plan(Strategy::NotNeeded, &CjkInstall::Unknown),
Step::Apply(CjkInstall::NotNeeded)
);
assert_eq!(
plan(Strategy::System { family: "SimSun" }, &CjkInstall::Unknown),
Step::Apply(CjkInstall::System {
family: "SimSun".to_owned()
})
);
assert_eq!(
plan(Strategy::Unprobeable, &CjkInstall::Downloading { bytes: 1 }),
Step::Apply(CjkInstall::Unprobeable),
"探测不出也要把状态改回来(否则界面会停在「正在下载」)"
);
}
/// 截图钉子(D30):每个 `ULL_FONT_STATUS` 取值都能解析成一档状态,坏值不猜。
+10 -8
View File
@@ -55,12 +55,15 @@ pub(super) fn start(font: &'static font::CjkFont) {
}
};
let cached = fetcher.has_cached();
let cache_file = fetcher.cache_file();
update(CjkInstall::Downloading { bytes });
eprintln!(
"[font] cjk-download start family=\"{}\" bytes={bytes} from_cache={cached} file={}",
"[font] cjk-download start family=\"{}\" bytes={bytes} file={} cache_present={}",
font.family,
fetcher.cache_file().display()
cache_file.display(),
// 这条只是「文件在不在」(诊断用),**不是** from_cache:坏缓存会被删掉重下,
// 真正的命中结论在 `fetcher.was_cached()`(取完之后)。
fetcher.has_cached()
);
// 判据说「需要」,这条线程才存在:不需要的三种情形在 `after_probe` 就返回了。
@@ -70,12 +73,11 @@ pub(super) fn start(font: &'static font::CjkFont) {
&fetcher,
&register_into_font_system,
);
// `from_cache` 只认下载器**这次**的结论(复审 r1 次要项):取之前「文件在不在」会说谎——
// 坏缓存会被删掉重下,那时 `has_cached()` 还是 true,而真相是「联网下了一次」。
let from_cache = fetcher.was_cached().unwrap_or(false);
let state = match state {
// 命中缓存的那一份要把 `from_cache` 标出来(状态行靠它区分「等了一会儿」与「真的下了一次」)。
CjkInstall::Ready { family, .. } => CjkInstall::Ready {
family,
from_cache: cached,
},
CjkInstall::Ready { family, .. } => CjkInstall::Ready { family, from_cache },
other => other,
};
match &state {
@@ -134,7 +134,7 @@ pub(super) fn font_status(
}
}
/// 字节数 → 人话体积(`17.0 MB`)。只用于「≈」级别的量,不追求精确位数。
/// 字节数 → 人话体积(17 772 300 B → `16.9 MB`,按 1 MiB = 1048576 B)。只用于「≈」级别的量。
fn format_size(bytes: u64) -> String {
const MIB: f64 = 1024.0 * 1024.0;
let mib = bytes as f64 / MIB;
+5 -1
View File
@@ -139,8 +139,12 @@ impl LangState {
report.strategy,
unlucky_i18n::font::Strategy::NeedsDownload(_)
) {
// M168:这里**不再**说「通道还没做」——下载在这一拍之后就已经发起(同一份报告交给
// `fonts::install::after_probe`),结果与状态在 `[font] cjk-download …` 那几行里,
// 也在设置页 General 组的状态行上。上一版这句 stderr 在下载发起后说「还是 PendingPin」,
// 等于当面否定刚做的事(复审 r1 P2-3)。
eprintln!(
"[i18n] 缺中文字体:界面会是中文但字形可能缺失(D28 的下载通道还是 PendingPin)"
"[i18n] 缺中文字体:已按 D28 发起下载,进度与结果见 [font] cjk-download … 与设置页「中文字体」那一行;失败会降级用系统字体"
);
}
}