fix(m168): 复审 r3 四项 —— 说准 panic 档、给两条修复补守卫、时限算术补洞

复审 r3(p2-4items / fix-then-merge)四项 + 顺手五条:

1. **P2-1 `catch_unwind` 在发布档是死代码,三处文档说了不成立的话**:
   `Cargo.toml` 的 `[profile.release]` 是 `panic = "abort"`(只有 `fast` 是 `unwind`),发布走
   `--release`。分两半写清楚:①**闸前移仍安全**——release 档 panic 是进程退出,不存在永久停在
   `Downloading`;`dev`/`test`/`fast` 档 `catch_unwind` 真会写 `Failed`;②但「下载失败不崩」在
   panic 这一支上对发布版**是反的**:注册/`load_font` panic ⇒ 启动器整体 abort,不是降级。
   `install.rs::plan_with_offline` 的文档(新增两档对照表)、`worker.rs` 模块文档与 `run` 注释全部
   按这个说法重写,不再用 `catch_unwind` 给「不崩」背书。

2. **P2-2 「启动不发起下载」这条修复没有守卫**:拆出纯函数
   `fonts::resolve_with_faces(setting, faces)`(`resolve_with` 只是 `system_faces()` 的包装),
   新增 `fonts::tests::resolving_fonts_never_starts_a_download`:给一张只有拉丁族的脸表
   (一定判 `NeedsDownload`),断言调用后 `install::status()` **不是** `Downloading`。
   变异(实跑):把 `install::after_probe(cjk_report.strategy)` 加回 `resolve_with_faces` ⇒
   `求值字体**不许**发起下载(状态从 Unknown 变成了 Downloading { bytes: 17772300 })`,红。

3. **P2-3 时限算术有洞 + `is_last` 没守卫**:改 `ATTEMPT_DEADLINE=60 s` / `TOTAL_DEADLINE=420 s`
   (原来 120/360 时,第一个候选吃掉 120 s 后最后一个只剩 240 s ⇒ 门槛 72.3 KiB/s,r1 实测
   67 KiB/s 需要 259 s,仍然必失败)。文档按新数值重算成一张表:第一个候选瞬间失败 ⇒ 420 s ⇒
   42.3 KiB/s;爬满 60 s 被切 ⇒ 360 s ⇒ 49.4 KiB/s,两者都低于实测最慢的 67 KiB/s。
   守卫:把同解的旧用例换成 **单候选 + attempt 300 ms / total 5 s**——有 `is_last` 时跑到总时限
   (≈5 s),没有时 300 ms 就切,两种实现可分辨。变异(实跑):删掉 `is_last` ⇒
   `the_last_candidate_gets_the_whole_remaining_budget` 红在
   `最后一个候选该拿整个剩余预算(≈5 s)…452.6035ms`。

4. **P2-4(b)(c)**:①`install::tests` 里三处判据用例改用 `plan_with_offline(..., false)`,把进程级
   `OFFLINE` 从判据里摘出去(否则同进程里 `headless(screenshot_options())` 会把它置 true 且从不
   还原 ⇒ 结果依赖执行顺序);②`fonts.rs` 注释改成真实机制——出图 harness 不经
   `App::with_presenter`、压根没有 `set_offline`,它零流量靠的是「`resolve_with` 不触发」。

顺手:①`sha256_mismatch…` 的 doc 改成「两层校验互备,本用例抓的是两层同时拆」(原来说
「拆任意一层就红」是假的);②`font.rs` 文档「把 90 s 压成 300 ms」→ 60 s/420 s;③`install.rs` 与
`catalog/font_install.rs` 的「≈17 MB」→ 16.9;④`worker::start` 的裸 `thread::spawn` 换成
`Builder::spawn`,起不来就落 `Failed`(发布档 panic = abort,不能让它 panic);线程体抽成 `run()`。

验证(`CARGO_TARGET_DIR=C:/Temp/ull-target/wt-168`,取退出码、不截断):
- `cargo test -p unlucky-app-iced --lib` → **688 passed / 0 failed / 2 ignored / exit 0**
- `cargo test -p unlucky-core --all-targets` → **16 个 target / 1189 passed / 0 failed / exit 0**
- `cargo test -p unlucky-i18n` → 31 passed / 0 failed / exit 0
- `cargo clippy -p unlucky-i18n -p unlucky-core -p unlucky-app-iced --all-targets -- -D warnings` → exit 0
- `cargo fmt --all -- --check` 干净;全仓无文件 >400;本轮改动文件最大 378(`app-iced/src/fonts.rs`)
This commit is contained in:
AskaEth committed 2026-11-08 14:38:25 +08:00
1 parent b9009c0e61
commit 460da9330d
9 files changed
+231 -103

No files matched your search

+16 -6
View File
@@ -21,7 +21,7 @@
//!
//! ## 为什么下载在后台线程而不是启动那一拍
//!
//! 一份 17 MiB 的字体在弱网下要几十秒。启动编排(`app/boot.rs`)是在**开窗之前**同步跑的,
//! 一份 16.9 MiB 的字体在弱网下要几十秒。启动编排(`app/boot.rs`)是在**开窗之前**同步跑的,
//! 在那里下载等于「点了图标,窗口几十秒不出来」。所以:启动路径只**发起**,窗口照常起来,
//! 状态行(设置页 General 组)与 stderr 日志承担「正在获取 / 完成 / 失败」这三句话。
//!
@@ -97,7 +97,7 @@ pub enum CjkInstall {
Unprobeable,
/// 正在下载。
Downloading {
/// 字节数(pin 里的期望值;用来写「≈17 MB」这种可核对的量)。
/// 字节数(pin 里的期望值;用来写「16.9 MB」这种可核对的量)。
bytes: u64,
},
/// 已就绪(下载完成并注册进字体系统)。
@@ -263,8 +263,18 @@ pub enum Step {
/// 会走 `Apply(Unprobeable)` —— 把状态从「正在下载」改回「无法探测」,而**线程还在飞**:
/// 下完之后 `Ready` 又把它盖回去,状态行在 30 秒里来回翻。当时给出的理由是「否则界面会停在
/// 正在下载」——那个理由只对**僵死**的 `Downloading` 成立(线程 panic 才会僵死),而在飞是
/// 常见情形。僵死那一半现在由 worker 的 `catch_unwind` 兜底(任何 panic 都会写终态),
/// 所以「下载中一律 Skip」不再有卡死的风险。
/// 常见情形。
///
/// ## 「僵死」那一半按构建档分两说(复审 r3 P2-1,别再当成一条统一的兜底)
///
/// | 档 | panic 语义 | 线程 panic 之后 |
/// |---|---|---|
/// | `release`(`Cargo.toml` 的 `panic = "abort"`,发布就走它) | 进程直接 abort | 不存在「停在 Downloading」,因为**进程没了**;反过来说「下载失败不崩」在 panic 这一支上对本档**不成立**(注册路径 panic ⇒ 启动器整体死掉,不是降级) |
/// | `dev` / `test` / `fast`(unwind) | 栈展开 | `worker` 的 `catch_unwind` 把状态写成 `Failed`,闸因此不会卡死 |
///
/// 也就是说:**闸前移在两种档下都安全**(release 靠「进程退出」,unwind 档靠 `catch_unwind`),
/// 但 `catch_unwind` **不能**用来给「下载失败不崩」背书——它只在 unwind 档存在。字体注册路径的
/// panic 在发布版仍会让启动器 abort,这是本 mission 未解决的一项(报告里列为未验/风险)。
///
/// `offline = true`(出图通道)时**永不** `Start`:D30 要求同一份代码出同一张图,而下载会额外
/// 产生网络流量并可能在出图过程中注册新字体。
@@ -294,7 +304,7 @@ pub fn plan(strategy: Strategy, current: &CjkInstall) -> Step {
/// 判据结论 → 现在这一步的结局(生产入口;判据全在 [`plan`],这里只执行)。
///
/// * 已经在下载 / 已就绪 → 什么都不做(幂等:切一次语言不会起第二条下载、不会重复注册 17 MiB);
/// * 已经在下载 / 已就绪 → 什么都不做(幂等:切一次语言不会起第二条下载、不会重复注册 16.9 MiB);
/// * 英文环境 / 系统已有 / 探测不出 → 只更新状态(零网络);
/// * 上次失败过 → 只有再次切语言才会重试(用户显式动作),不会自己反复联网;
/// * 出图通道([`set_offline`])→ 永不发起。
@@ -315,7 +325,7 @@ pub fn after_probe(strategy: Strategy) -> CjkInstall {
/// 本进程是否**禁止**发起字体下载(出图通道用;D30:同一份代码出同一张图)。
///
/// 为什么需要它:截图/演示通道也会走 `App` 构造 → `lang.log_cjk()` → `cjk_for` → `after_probe`。
/// 一台 zh-locale、没有中文字体的机器上,出图会真的下 17 MiB 并在出图过程中注册新字体——
/// 一台 zh-locale、没有中文字体的机器上,出图会真的下 16.9 MiB 并在出图过程中注册新字体——
/// 这既产生流量,也让「同一份代码出同一张图」不再成立。
pub fn set_offline(offline: bool) {
OFFLINE.store(offline, std::sync::atomic::Ordering::Relaxed);
+22 -8
View File
@@ -178,7 +178,7 @@ fn a_started_or_finished_install_is_not_restarted() {
for current in [downloading, ready] {
assert_eq!(
plan(Strategy::NeedsDownload(&NOTO_SANS_SC), &current),
plan_with_offline(Strategy::NeedsDownload(&NOTO_SANS_SC), &current, false),
Step::Skip,
"{current:?}:已经在下载 / 已就绪时再问一次,必须什么都不做"
);
@@ -194,14 +194,18 @@ fn a_started_or_finished_install_is_not_restarted() {
},
] {
assert_eq!(
plan(Strategy::NeedsDownload(&NOTO_SANS_SC), &current),
plan_with_offline(Strategy::NeedsDownload(&NOTO_SANS_SC), &current, false),
Step::Start(&NOTO_SANS_SC),
"{current:?}:需要下载且还没成就该发起(失败后的重试也只能走这条)"
);
}
}
/// 三种「不需要下载」的策略在 [`plan`] 里只产生 `Apply`(**零网络**),且状态文案对得上。
/// 三种「不需要下载」的策略在判据表里只产生 `Apply`(**零网络**),且状态文案对得上。
///
/// 这里显式给 `offline = false`(`plan_with_offline`)而不是用 `plan`:`plan` 会读进程级的
/// `set_offline` 开关,而同进程里 `headless(screenshot_options())` 那 20+ 处会把它置 true 且
/// 从不还原(复审 r3 P2-4 ①)——用 `plan` 的话这三条的结果取决于执行顺序。
///
/// 抓得住:谁把 `NotNeeded` / `System` / `Unprobeable` 的臂改成 `Start`(那就会替英文用户偷偷
/// 下一次 17 MiB);也抓得住「系统已有字体」被记成「需要下载」这种状态映射错。
@@ -209,11 +213,15 @@ fn a_started_or_finished_install_is_not_restarted() {
#[test]
fn the_idle_strategies_only_apply_state() {
assert_eq!(
plan(Strategy::NotNeeded, &CjkInstall::Unknown),
plan_with_offline(Strategy::NotNeeded, &CjkInstall::Unknown, false),
Step::Apply(CjkInstall::NotNeeded)
);
assert_eq!(
plan(Strategy::System { family: "SimSun" }, &CjkInstall::Unknown),
plan_with_offline(
Strategy::System { family: "SimSun" },
&CjkInstall::Unknown,
false
),
Step::Apply(CjkInstall::System {
family: "SimSun".to_owned()
})
@@ -221,9 +229,15 @@ fn the_idle_strategies_only_apply_state() {
// 复审 r2 P2-2 ② 改判:**下载中一律 Skip**,即使策略说「探测不出」。
// 上一版在这里断言 `Apply(Unprobeable)`,理由是「否则界面会停在正在下载」——那个理由只对
// 僵死成立(线程 panic),而在飞是常见情形:线程还在下,状态却被改成「无法探测」,下完又
// 翻回「已就绪」,状态行在几十秒里来回翻。僵死由 worker 的 catch_unwind 兜底。
// 翻回「已就绪」,状态行在几十秒里来回翻。「僵死」那一半按构建档分两说(worker 模块文档):
// unwind 档(dev/test/fast)由 `catch_unwind` 兜底;发布档 `panic = "abort"` 是进程退出,
// 不存在停在 Downloading 的状态。
assert_eq!(
plan(Strategy::Unprobeable, &CjkInstall::Downloading { bytes: 1 }),
plan_with_offline(
Strategy::Unprobeable,
&CjkInstall::Downloading { bytes: 1 },
false
),
Step::Skip,
"在飞的下载不许被别的策略改写状态"
);
@@ -244,7 +258,7 @@ fn every_strategy_skips_while_a_download_is_in_flight() {
Strategy::Unprobeable,
] {
assert_eq!(
plan(strategy, &downloading),
plan_with_offline(strategy, &downloading, false),
Step::Skip,
"{strategy:?}:下载中不许改状态、不许起第二条线程"
);
+67 -42
View File
@@ -9,23 +9,35 @@
//!
//! ## 为什么下载在后台线程
//!
//! 一份 17 MiB 的字体在弱网下要几十秒,而两个触发点(启动编排、设置页切语言)都在 UI 线程上。
//! 在 UI 线程同步下完 = 窗口几十秒不响应(启动路径甚至是「窗口还没出来」)。
//! 一份 16.9 MiB 的字体在弱网下要几十秒,而触发点(设置页切语言)在 UI 线程上。
//! 在 UI 线程同步下完 = 窗口几十秒不响应。
//!
//! ## 线程里做了什么(顺序即判据)
//!
//! ```text
//! CdnFetcher(缓存命中 ⇒ 零网络;否则共享引擎下载 + sha256 复验)
//! CdnFetcher(缓存命中 ⇒ 零网络;否则共享引擎下载 + sha256 复验 + 整体时限)
//! → i18n::font::ensure 只有 NeedsDownload 才调 fetcher
//! → 成功:注册进 iced,状态 Ready{family, from_cache}
//! → 失败:状态 Failed{class},**什么都不注册**(降级回系统字体,界面照常可用)
//! ```
//!
//! ## panic 这件事分两档说(复审 r3 P2-1)
//!
//! | 档 | panic 语义 | 线程 panic 之后 |
//! |---|---|---|
//! | `release`(`Cargo.toml`:`panic = "abort"`,发布走它) | 进程 abort | 状态槽随进程消失(**不会**停在 Downloading);但「降级回系统字体」**也不存在**——启动器整体退出 |
//! | `dev` / `test` / `fast`(unwind) | 栈展开 | [`run`] 里的 `catch_unwind` 把状态写成 `Failed` |
//!
//! 所以 `catch_unwind` 是**给 unwind 档的**,不能拿它给「下载失败不崩」背书:发布版在注册路径
//! panic 时仍会 abort。要让发布版也降级,得让注册路径不 panic(或改成 `panic = "unwind"`,
//! 那属于发布 profile 的决定,不在本 mission)。
//!
//! ## 诚实边界
//!
//! - **抓得住**:状态流转、失败不注册、日志三行(start / ok / failed);
//! - **抓不住**:真机上字体注册之后**出字的样子**(cosmic-text 用 `wght` 轴选字重这件事只核对了
//! 上游源码,没有真机截图对照;macOS / Linux 完全没有真机)。
//! 上游源码,没有真机截图对照;macOS / Linux 完全没有真机),也抓不住发布档的 abort 行为
//! (那要真跑一个 `--release` 的 panic 用例)。
use unlucky_i18n::font::{self, Strategy};
@@ -34,6 +46,9 @@ use crate::fonts::cdn::CdnFetcher;
use crate::platform::data;
/// 起一条后台下载(进程内同时只允许一条:`install::after_probe` 负责那道闸)。
///
/// 状态先落 `Downloading` 再起线程;**线程起不来时**立刻落 `Failed`(用 `Builder::spawn` 而不是
/// 裸 `thread::spawn`:后者失败会 panic,而发布档 `panic = "abort"` ⇒ 整个启动器退出)。
pub(super) fn start(font: &'static font::CjkFont) {
let font::Source::Pinned { bytes, .. } = font.source;
let Some(cache_root) = data::cache_root() else {
@@ -66,44 +81,54 @@ pub(super) fn start(font: &'static font::CjkFont) {
fetcher.has_cached()
);
// 判据说「需要」,这条线程才存在:不需要的三种情形在 `after_probe` 就返回了。
std::thread::spawn(move || {
// **保证写终态**(复审 r2 P2-2 ② 把这条当成「下载中一律 Skip」的前提):
// 线程里任何 panic(库内部、注册路径、OOM 前的 unwrap)都要落成 `Failed`,
// 否则状态会永远停在「正在获取中文字体…」而幂等闸又不许别人改它。
let state = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
ensure_for(
Strategy::NeedsDownload(font),
&fetcher,
&register_into_font_system,
)
})) {
Ok(state) => state,
Err(_) => CjkInstall::Failed {
class: FetchClass::Disk,
message: "字体安装线程 panic(已捕获,状态回到终态;界面继续用系统字体)"
.to_owned(),
},
};
// `from_cache` 只认下载器**这次**的结论(复审 r1 次要项):取之前「文件在不在」会说谎——
// 坏缓存会被删掉重下,那时 `has_cached()` 还是 true,而真相是「联网下了一次」。
let from_cache = fetcher.was_cached().unwrap_or(false);
let state = match state {
CjkInstall::Ready { family, .. } => CjkInstall::Ready { family, from_cache },
other => other,
};
match &state {
CjkInstall::Ready { family, from_cache } => eprintln!(
"[font] cjk-download ok family=\"{family}\" from_cache={from_cache} (中文字形现在由它兜底,界面不需要重启)"
),
CjkInstall::Failed { class, message } => eprintln!(
"[font] cjk-download failed class={} {message}(降级:继续用系统字体,界面照常可用)",
class.as_str()
),
other => eprintln!("[font] cjk-download {}", other.describe()),
}
update(state);
});
let spawned = std::thread::Builder::new()
.name("ull-cjk-font".to_owned())
.spawn(move || run(font, fetcher));
if let Err(error) = spawned {
update(CjkInstall::Failed {
class: FetchClass::Disk,
message: format!("起不了字体下载线程({error})"),
});
}
}
/// 线程体:取字体 → 注册 → 写终态(**一定**写终态,见模块文档「panic 这件事分两档说」)。
fn run(font: &'static font::CjkFont, fetcher: CdnFetcher) {
let state = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
ensure_for(
Strategy::NeedsDownload(font),
&fetcher,
&register_into_font_system,
)
})) {
Ok(state) => state,
Err(_) => CjkInstall::Failed {
class: FetchClass::Disk,
message: "字体安装线程 panic(已捕获,状态回到终态;界面继续用系统字体)".to_owned(),
},
};
// `from_cache` 只认下载器**这次**的结论(复审 r1 次要项):取之前「文件在不在」会说谎——
// 坏缓存会被删掉重下,那时 `has_cached()` 还是 true,而真相是「联网下了一次」。
let from_cache = fetcher.was_cached().unwrap_or(false);
let state = match state {
CjkInstall::Ready { family, .. } => CjkInstall::Ready { family, from_cache },
other => other,
};
match &state {
CjkInstall::Ready { family, from_cache } => eprintln!(
"[font] cjk-download ok family=\"{family}\" from_cache={from_cache} \
(中文字形现在由它兜底,界面不需要重启)"
),
CjkInstall::Failed { class, message } => eprintln!(
"[font] cjk-download failed class={} {message}(降级:继续用系统字体,界面照常可用)",
class.as_str()
),
other => eprintln!("[font] cjk-download {}", other.describe()),
}
update(state);
}
/// 把字体字节注册进 iced 的**全局字体系统**(`iced_graphics` 的 `FontSystem::load_font`)。
+39
View File
@@ -173,3 +173,42 @@ fn type_roles_pick_the_display_stack_for_big_text() {
);
assert_eq!(TypeRole::BodyMedium.style().family, FontFamily::Text);
}
/// **求值字体不许发起下载**(复审 r3 P2-2:上一轮的修复没有任何用例守着)。
///
/// 用 `resolve_with_faces` 而不是 `resolve_with`:`system_faces()` 不可注入,拿不到「一台没有
/// 中文字体的机器」。这里给一张只有拉丁族的脸表 ⇒ 中文环境判出 `NeedsDownload`。
///
/// 抓得住:把 `install::after_probe(cjk_report.strategy)` 加回求值路径(那正是 r2 判为 bug 的写法)
/// ——状态槽会从 `NotNeeded`/`Unknown` 变成 `Downloading`,下面那条断言当场红。
/// 复审 r3 实测:加回去整套 `unlucky-app-iced --lib` **687 passed / 0 failed**(当时没有这条)。
/// 抓不住:`App` 构造之后 `log_cjk` 那一拍会不会触发(那是 `cjk_for` 的职责,且它本来就该触发)。
#[test]
fn resolving_fonts_never_starts_a_download() {
use unlucky_i18n::font::Strategy;
use crate::fonts::install;
// 只有拉丁族 ⇒ 中文环境一定判 needs-download,于是这条用例对「触发」是敏感的。
let latin: Vec<Face> = ["Segoe UI", "Consolas"]
.iter()
.map(|family| Face {
family: (*family).to_owned(),
weight: 400,
})
.collect();
let before = install::status();
let resolved = resolve_with_faces(Some("zh_cn"), Some(&latin));
let after = install::status();
assert_eq!(
resolved.cjk.strategy,
Strategy::NeedsDownload(&unlucky_i18n::font::NOTO_SANS_SC),
"这张脸表必须判出「需要下载」,否则下面的断言证明不了什么"
);
assert!(
!after.is_downloading(),
"求值字体**不许**发起下载(状态从 {before:?} 变成了 {after:?})"
);
}