复审 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`)
165 lines
7.3 KiB
Rust
165 lines
7.3 KiB
Rust
//! 下载线程与字体注册(M168):`install.rs` 的「动作」那一半。
|
||
//!
|
||
//! ## 为什么拆出来
|
||
//!
|
||
//! `install.rs` 是「状态 + 判据 + 触发」,已经贴着 400 行硬线。这里只放真正干活的两件:
|
||
//!
|
||
//! 1. [`start`]:起一条后台线程(进程内同时只有一条),把「正在下载 → 结果」写回状态槽;
|
||
//! 2. [`register_into_font_system`]:把字节交给 iced 的全局字体系统(运行时注册)。
|
||
//!
|
||
//! ## 为什么下载在后台线程
|
||
//!
|
||
//! 一份 16.9 MiB 的字体在弱网下要几十秒,而触发点(设置页切语言)在 UI 线程上。
|
||
//! 在 UI 线程同步下完 = 窗口几十秒不响应。
|
||
//!
|
||
//! ## 线程里做了什么(顺序即判据)
|
||
//!
|
||
//! ```text
|
||
//! 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 完全没有真机),也抓不住发布档的 abort 行为
|
||
//! (那要真跑一个 `--release` 的 panic 用例)。
|
||
|
||
use unlucky_i18n::font::{self, Strategy};
|
||
|
||
use super::{ensure_for, update, CjkInstall, FetchClass};
|
||
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 {
|
||
update(CjkInstall::Failed {
|
||
class: FetchClass::Disk,
|
||
message: "数据根解析不出来,字体无处可落".to_owned(),
|
||
});
|
||
return;
|
||
};
|
||
|
||
let fetcher = match CdnFetcher::new(font, cache_root) {
|
||
Ok(fetcher) => fetcher,
|
||
Err(error) => {
|
||
update(CjkInstall::Failed {
|
||
class: FetchClass::from(&error),
|
||
message: error.message().to_owned(),
|
||
});
|
||
return;
|
||
}
|
||
};
|
||
|
||
let cache_file = fetcher.cache_file();
|
||
update(CjkInstall::Downloading { bytes });
|
||
eprintln!(
|
||
"[font] cjk-download start family=\"{}\" bytes={bytes} file={} cache_present={}",
|
||
font.family,
|
||
cache_file.display(),
|
||
// 这条只是「文件在不在」(诊断用),**不是** from_cache:坏缓存会被删掉重下,
|
||
// 真正的命中结论在 `fetcher.was_cached()`(取完之后)。
|
||
fetcher.has_cached()
|
||
);
|
||
|
||
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,
|
||
®ister_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`)。
|
||
///
|
||
/// 返回注册后的族名(从字体库里读回来的):变量字体的 `name` 表里族名是 `Noto Sans SC`、
|
||
/// 而默认实例是 Thin,所以**不能**假设「注册了就叫 NOTO_SANS_SC.family」——这里读回来核对。
|
||
///
|
||
/// # Errors
|
||
/// 字体系统锁被毒掉、或注册后字体库里找不到那个族(字节不是字体 / `fontdb` 不认)。
|
||
pub fn register_into_font_system(bytes: Vec<u8>) -> Result<String, String> {
|
||
let lock = iced::advanced::graphics::text::font_system();
|
||
let mut font_system = lock
|
||
.write()
|
||
.map_err(|_| "字体系统的读写锁被毒掉(另一处注册 panic 过)".to_owned())?;
|
||
|
||
font_system.load_font(std::borrow::Cow::Owned(bytes));
|
||
|
||
// 先把族名抄出来再判:`faces()` 的迭代器借着 `font_system`,直接链到 `ok_or_else` 会让
|
||
// 那个借用活过守卫(E0597)。
|
||
let families: Vec<String> = font_system
|
||
.raw()
|
||
.db()
|
||
.faces()
|
||
.flat_map(|face| face.families.iter())
|
||
.map(|(family, _)| family.clone())
|
||
.collect();
|
||
|
||
families
|
||
.iter()
|
||
.find(|family| family.as_str() == font::NOTO_SANS_SC.family)
|
||
.cloned()
|
||
.ok_or_else(|| format!("注册后在字体库里找不到 \"{}\"", font::NOTO_SANS_SC.family))
|
||
}
|