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:
1 parent
b9009c0e61
commit
460da9330d
9 files changed
+231
-103
No files matched your search
@@ -186,16 +186,30 @@ fn register_termux_fonts() {
|
||||
}
|
||||
|
||||
/// 同 [`resolve`],但把配置里 `ui.language` 的显式值喂给中文策略(D28 的第一级)。
|
||||
///
|
||||
/// 只做「取真实字体库 + 交给 [`resolve_with_faces`]」两件事:真正的求值在后者,于是
|
||||
/// **「启动这一拍不发起下载」这件事有测试可打**(复审 r3 P2-2:`system_faces()` 不可注入,
|
||||
/// 上一轮的修复没有任何用例守着,把 `after_probe` 加回来整套测试仍绿)。
|
||||
pub fn resolve_with(setting: Option<&str>) -> Resolved {
|
||||
register_termux_fonts();
|
||||
resolve_with_faces(setting, system_faces().as_deref())
|
||||
}
|
||||
|
||||
/// [`resolve_with`] 的**纯**求值:给定「某族装没装」的字面表,算出 [`Resolved`]。
|
||||
///
|
||||
/// `faces = None` 表示字体库读不出来(→ 中文策略 `Unprobeable`,不是「缺字体」)。
|
||||
///
|
||||
/// **这里不发起下载**(复审 r2 P2-2 ① / r3 P2-2):启动那一拍(`boot.rs` / `pages/harness/app.rs`)
|
||||
/// 拿到的 `setting` 是 `None`,只按系统 locale 判——zh-locale + 配置 `en_us` 的机器若在这里触发,
|
||||
/// 就会白下 17 MiB,而随后 `App` 读到配置又把状态翻成「当前界面语言不需要中文字体」。
|
||||
/// 唯一的触发点是 [`cjk_for`](`ui/lang.rs::log_cjk`,那一拍已经知道**生效语言**)。
|
||||
///
|
||||
/// 这条不变量由 `fonts::tests::resolving_fonts_never_starts_a_download` 守着。
|
||||
pub fn resolve_with_faces(setting: Option<&str>, faces: Option<&[Face]>) -> Resolved {
|
||||
let platform = Platform::current();
|
||||
let faces = system_faces();
|
||||
|
||||
let is_installed = |family: &str| -> bool {
|
||||
faces
|
||||
.as_ref()
|
||||
.is_none_or(|faces: &Vec<Face>| faces.iter().any(|face| face.family == family))
|
||||
faces.is_none_or(|faces: &[Face]| faces.iter().any(|face| face.family == family))
|
||||
};
|
||||
|
||||
let families = [
|
||||
@@ -219,15 +233,11 @@ pub fn resolve_with(setting: Option<&str>) -> Resolved {
|
||||
// 字重:**优先用系统里这个族真实提供的档位**(令牌表的 `WeightCoverage` 是「这个族通常有哪几档」,
|
||||
// 装机版可能是静态子集:Win Server / 精简版只有经典 Segoe UI,没有 500)。查不到才回落到令牌纯函数。
|
||||
let weights = [Weight::Regular, Weight::Medium, Weight::Bold]
|
||||
.map(|weight| weight_report(platform, families[0].family, weight, faces.as_deref()));
|
||||
.map(|weight| weight_report(platform, families[0].family, weight, faces));
|
||||
|
||||
// D28 后半:语言 + 中文环境的中文字体策略。`faces` 的 `None` 在这里是「字体库读不出来」,
|
||||
// 适配层会把它翻成 `Unprobeable` 而不是「缺字体」(否则会误触发一次 CDN 下载)。
|
||||
let cjk_report = cjk::probe_with(setting, faces.as_deref());
|
||||
// **这里不发起下载**(复审 r2 P2-2 ①):启动那一拍(`boot.rs` / `pages/harness/app.rs`)拿到的
|
||||
// `setting` 是 `None`,只按系统 locale 判——zh-locale + 配置 `en_us` 的机器会在这里就下 17 MiB,
|
||||
// 而随后 `App` 读到配置、把状态翻成「当前界面语言不需要中文字体」,线程却还在下。
|
||||
// 真正的触发点只有一个:[`cjk_for`](`ui/lang.rs::log_cjk`,那一拍已经知道**生效语言**)。
|
||||
let cjk_report = cjk::probe_with(setting, faces);
|
||||
|
||||
Resolved {
|
||||
platform,
|
||||
@@ -247,8 +257,12 @@ pub fn resolve_with(setting: Option<&str>) -> Resolved {
|
||||
/// **为什么触发只留在这里**(复审 r2 P2-2 ①):这一拍的 `setting` 与 `LangChoice` 同源
|
||||
/// (`ULL_LANG` > 配置 > 系统 locale),也就是「界面实际用哪种语言」的那一份判据。
|
||||
/// 放在 [`resolve`] 里就会用「系统 locale」代表「用户选择」,zh-locale + 配置 `en_us` 的机器
|
||||
/// 会白下一次 17 MiB。出图通道(`--screenshot`)由 `install::set_offline(true)` 兜住,
|
||||
/// 仍然一次都不发。
|
||||
/// 会白下一次 17 MiB。
|
||||
///
|
||||
/// 两条通道各自靠什么做到「一次都不发」:**出图 harness**(`pages/harness/app.rs`)不经
|
||||
/// `App::with_presenter`,它零流量靠的是「[`resolve_with`] 根本不触发」;**`--screenshot`**
|
||||
/// 走真 `App`,除了同一条不变量,还有 `install::set_offline(true)` 这道闸。别把前者的功劳
|
||||
/// 记到 `set_offline` 上(复审 r3 P2-4 ②)。
|
||||
pub fn cjk_for(setting: Option<&str>) -> CjkReport {
|
||||
let report = cjk::probe_with(setting, system_faces().as_deref());
|
||||
install::after_probe(report.strategy);
|
||||
@@ -256,8 +270,11 @@ pub fn cjk_for(setting: Option<&str>) -> CjkReport {
|
||||
}
|
||||
|
||||
/// 一条系统字面(族名 + 字重)。
|
||||
///
|
||||
/// `pub` 是因为 [`resolve_with_faces`] 把它作为参数暴露出去(那是「启动不发起下载」的唯一
|
||||
/// 可测入口,复审 r3 P2-2)——`private_interfaces` 会拦「pub 函数收私有类型」。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct Face {
|
||||
pub struct Face {
|
||||
family: String,
|
||||
weight: u16,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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), ¤t),
|
||||
plan_with_offline(Strategy::NeedsDownload(&NOTO_SANS_SC), ¤t, 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), ¤t),
|
||||
plan_with_offline(Strategy::NeedsDownload(&NOTO_SANS_SC), ¤t, 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:?}:下载中不许改状态、不许起第二条线程"
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
®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);
|
||||
});
|
||||
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`)。
|
||||
|
||||
@@ -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:?})"
|
||||
);
|
||||
}
|
||||
@@ -66,31 +66,38 @@ pub const MAX_FONT_BYTES: u64 = 32 * 1024 * 1024;
|
||||
/// | Fastly 的 IPv6 边缘 | 60 s 只到 4.0 MB(≈67 KB/s,17.7 MB 要 ≈4 分钟) |
|
||||
/// | 同一域名的 IPv4 边缘 | 1.2 s 下完(≈15 MB/s) |
|
||||
///
|
||||
/// 120 s 是「差不多能过、但明显不对就换源」的位置:健康线路(实测 1.1~16.6 s)根本用不到它;
|
||||
/// 而 300 KB/s 以上的线路在 120 s 内能下完 17.7 MB。
|
||||
/// 60 s 是「明显不对劲就换源」的位置:健康线路(实测 1.1~16.6 s)根本用不到它。
|
||||
///
|
||||
/// ## 为什么**最后一个**候选不吃这个时限(复审 r2 P2-3)
|
||||
/// ## 为什么**最后一个**候选不吃这个时限(复审 r2 P2-3 / r3 P2-3)
|
||||
///
|
||||
/// 每个候选都从 0 字节重下(不做续传),所以「单次上限」直接就是一条**吞吐门槛**:
|
||||
/// `17 772 300 B / 上限`。第一版把两个候选都卡 90 s ⇒ 门槛 ≈197 KB/s,而 r1 实测那条 IPv6 是
|
||||
/// 67~77 KB/s——**改动前它能用 4 分钟下完,加了时限反而必失败、且重试无意义**。这正是我们想
|
||||
/// 避免的「把慢但当当能成的情况变成永久失败」。
|
||||
/// `17 772 300 B / 预算`。历史两版都踩在这里:90 s ⇒ 门槛 197 KB/s;120 s + 总 360 s ⇒
|
||||
/// 第一个候选吃掉 120 s 后最后一个只剩 240 s ⇒ 门槛 **72.3 KiB/s**,而 r1 实测那条 IPv6 是
|
||||
/// 67~77 KiB/s——**67 KiB/s 需要 259 s > 240 s,仍然必失败**(复审 r3 就是这么把上一版的
|
||||
/// 「49 KB/s」拆穿的:那个数是「第一个候选瞬间失败」的情形,不是这条改动要救的场景)。
|
||||
///
|
||||
/// 现在的规则(见 [`fetch_blocking_with_limits`]):前 n-1 个候选各拿
|
||||
/// `min(ATTEMPT_DEADLINE, 剩余)`,**最后一个候选拿整个剩余总预算**。所以只要这条线在
|
||||
/// [`TOTAL_DEADLINE`] 内下得完,它就还有一次老老实实下完的机会;而前面几个候选仍然会被快速换掉。
|
||||
/// **超时归入网络档**(用户视角是「太慢」,不是「内容不对」)。
|
||||
pub const ATTEMPT_DEADLINE: Duration = Duration::from_secs(120);
|
||||
/// `min(ATTEMPT_DEADLINE, 剩余)`,**最后一个候选拿整个剩余总预算**。
|
||||
///
|
||||
/// ## 三个数的完整算术(2 个候选的常见情形)
|
||||
///
|
||||
/// | 情形 | 最后一个候选拿到的预算 | 门槛 = 17 772 300 B / 预算 |
|
||||
/// |---|---|---|
|
||||
/// | 第一个候选瞬间失败 | 420 s | **42.3 KiB/s** |
|
||||
/// | 第一个候选爬满 60 s 被切 | 360 s | **49.4 KiB/s** |
|
||||
///
|
||||
/// 两者都低于 r1 实测最慢的 67 KiB/s(需要 259 s),于是「慢而当当能下完」的线路仍有**一次**
|
||||
/// 老老实实下完的机会。**超时归入网络档**(用户视角是「太慢」,不是「内容不对」)。
|
||||
pub const ATTEMPT_DEADLINE: Duration = Duration::from_secs(60);
|
||||
|
||||
/// 整条取回(所有候选加起来)的总时限,也是**最后一个候选**能拿到的预算上限。
|
||||
///
|
||||
/// 取值依据(复审 r2 P2-3 的算术):17 772 300 B / 360 s ≈ **49 KB/s** —— 比 r1 实测最慢的
|
||||
/// 那条 IPv6 路径(67~77 KB/s)还低一档,于是「慢但当当能下完」的线路仍然有一次成功机会;
|
||||
/// 而任何健康线路(1.1~16.6 s)在第一或第二个候选上就结束了。
|
||||
/// 取值依据见 [`ATTEMPT_DEADLINE`] 的算术表:420 s 让「第一个候选爬满 60 s」这种最差情形
|
||||
/// 仍给最后一个候选 360 s(49.4 KiB/s 门槛)。
|
||||
///
|
||||
/// 代价:最坏情形下用户要在设置页看着「正在获取中文字体…(16.9 MB)」等 6 分钟。这是刻意的
|
||||
/// 代价:最坏情形下用户要在设置页看着「正在获取中文字体…(16.9 MB)」等 7 分钟。这是刻意的
|
||||
/// 取舍——**慢而最终成功**远好过**快而必然失败**(后者要用户自己再来一次,结果一样)。
|
||||
pub const TOTAL_DEADLINE: Duration = Duration::from_secs(360);
|
||||
pub const TOTAL_DEADLINE: Duration = Duration::from_secs(420);
|
||||
|
||||
/// 字体取回的失败分类(三层,见模块文档「失败分类为什么是三类」)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -169,7 +176,7 @@ pub fn fetch_blocking(cache_root: &Path, spec: &FontSpec) -> Result<FontFetch, F
|
||||
fetch_blocking_with_limits(cache_root, spec, ATTEMPT_DEADLINE, TOTAL_DEADLINE)
|
||||
}
|
||||
|
||||
/// 同 [`fetch_blocking`],但两个时限由调用方给(单测用它把 90 s 压成 300 ms)。
|
||||
/// 同 [`fetch_blocking`],但两个时限由调用方给(单测用它把 60 s / 420 s 压成几百毫秒)。
|
||||
///
|
||||
/// 为什么把时限做成参数而不是读环境变量:这是**行为**判据,不是诊断开关——测试要能证明
|
||||
/// 「一条只爬不死的连接会在时限处被打断并换源」,而 90 s 的用例没人会跑。
|
||||
|
||||
@@ -39,9 +39,12 @@ fn cache_hit_never_touches_the_network() {
|
||||
|
||||
/// 守卫 ②:**sha256 不符时拒绝落盘**(正式路径上什么都不许留下)。
|
||||
///
|
||||
/// 抓得住:把复验换成「信引擎的」或把中间文件直接当成品换名——本用例会在 `cache_path` 存在
|
||||
/// 与「错误类别」两处同时红。
|
||||
/// 抓不住:引擎自身的流式校验(那是 `transfer` 的守卫)。
|
||||
/// **两层校验是互备的**(复审 r3 顺手项①:上一版这里写「只拆一层也会红」是假的,复审变异实测
|
||||
/// 单独拆掉引擎的 `with_sha256` 或单独短路本模块复验,本用例都**仍然绿**——另一层会接住)。
|
||||
/// 本用例真正抓的是**两层同时被拆**:那时坏字节会被当成字体返回、`cache_path` 也会落盘,断言在
|
||||
/// `expect_err` 处红(r1 的变异证明就是这么打的)。
|
||||
///
|
||||
/// 抓不住:单层的实现细节(比如 `verify::matches` 的大小写处理)——那要各自的单元用例。
|
||||
#[test]
|
||||
fn sha256_mismatch_is_refused_and_nothing_lands() {
|
||||
let dir = root("mismatch");
|
||||
@@ -187,14 +190,22 @@ fn a_trickling_candidate_is_cut_by_the_attempt_deadline() {
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// 守卫(M168 复审 r2 P2-3):**总时限**也会打断爬行,且文案说清「超时」、不落盘。
|
||||
/// 守卫(M168 复审 r3 P2-3):**最后一个候选拿整个剩余预算**(`is_last` 那一支)。
|
||||
///
|
||||
/// 抓得住:总预算被当成「每个候选各给一次」⇒ 本用例会跑到 600 × 100 ms 的爬行结束(≈60 s),
|
||||
/// `elapsed < 4 s` 红;也抓得住超时被归成其它类别(`class()` 必须是 `network`)。
|
||||
/// 抓不住:真网络上的取值合不合适(同上,见「未验」)。
|
||||
/// 形状是刻意挑的:**单候选** + `attempt = 300 ms` / `total = 5 s`。
|
||||
/// * 有 `is_last`:最后一个(也是唯一一个)候选拿到剩下的 ~5 s ⇒ 只在**总时限**处被打断,
|
||||
/// 耗时 ≈5 s;
|
||||
/// * 没有 `is_last`(一律 `attempt.min(remaining)`):300 ms 就切,耗时 ≈0.3 s。
|
||||
///
|
||||
/// 两种实现的解不同,于是这条用例能分辨它们——上一版给的是 `attempt=30 s > total=400 ms`,
|
||||
/// 两种实现同解,天生测不出差别(复审 r3 就是据此说 `is_last` 没人守)。
|
||||
///
|
||||
/// 抓得住:删掉 `is_last` 那一支(或把预算写成 `attempt_deadline.min(remaining)` 一视同仁)⇒
|
||||
/// `elapsed >= 3 s` 当场红;也抓得住服务端其实不发字节(`proof` 的两个断言)。
|
||||
/// 抓不住:真弱网下 60 s / 420 s 够不够(要真 CDN,见报告「未验」)。
|
||||
#[test]
|
||||
fn the_total_deadline_cuts_a_single_crawling_candidate() {
|
||||
let dir = root("deadline-total");
|
||||
fn the_last_candidate_gets_the_whole_remaining_budget() {
|
||||
let dir = root("deadline-last");
|
||||
let body = fake_font(b"not what the server sends");
|
||||
let (trickle_url, proof) = serve_trickle();
|
||||
let spec = FontSpec::new(vec![trickle_url], sha256(&body), body.len() as u64).expect("pin");
|
||||
@@ -203,24 +214,29 @@ fn the_total_deadline_cuts_a_single_crawling_candidate() {
|
||||
let error = fetch_blocking_with_limits(
|
||||
&dir,
|
||||
&spec,
|
||||
Duration::from_secs(30),
|
||||
Duration::from_millis(400),
|
||||
Duration::from_millis(300),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.expect_err("总时限必须打断爬行");
|
||||
.expect_err("爬行连接最终必须被总时限打断");
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert_eq!(error.class(), "network", "超时属于网络档:{error}");
|
||||
assert!(
|
||||
error.message().contains("超时"),
|
||||
"文案要说得清是超时:{error}"
|
||||
);
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(4),
|
||||
"不该等到引擎的空闲超时:{:?}",
|
||||
started.elapsed()
|
||||
elapsed >= Duration::from_secs(3),
|
||||
"最后一个候选该拿整个剩余预算(≈5 s),而不是在 300 ms 处就被切:{elapsed:?}"
|
||||
);
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(9),
|
||||
"不该超过总时限太多:{elapsed:?}"
|
||||
);
|
||||
assert!(!cache_path(&dir, &spec).exists(), "超时不许落盘");
|
||||
assert!(
|
||||
proof.dripped.load(Ordering::SeqCst) >= 1,
|
||||
"总时限也该掐在「有字节在动」的连接上,而不是一个不响的端口"
|
||||
"这条用例必须掐在「有字节在动」的连接上,而不是一个不响的端口"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
//!
|
||||
//! | 状态 | 文案说什么 |
|
||||
//! |---|---|
|
||||
//! | 下载中 | 「正在获取中文字体…(≈17 MB)」——**没有**假进度条,有真实量(字节数)才写 |
|
||||
//! | 下载中 | 「正在获取中文字体…(16.9 MB)」——**没有**假进度条,有真实量(字节数)才写 |
|
||||
//! | 完成 | 「中文字体已就绪:⟨族名⟩」(复用 `FontCjkReady`) |
|
||||
//! | 失败 | 「获取失败(⟨三类原因之一⟩)。界面照常可用,用的是系统字体;再切一次语言可以重试」 |
|
||||
//! | 无需下载 | 系统已有字体 / 当前语言不需要,两条分开说(对用户是两件事) |
|
||||
|
||||
@@ -116,7 +116,7 @@ pub struct CjkFont {
|
||||
/// 一份、然后校验失败、然后降级」——比不下更糟。commit 是不变内容,哈希因此是可复现的判据。
|
||||
///
|
||||
/// 为什么是**一个**可变字体而不是三个静态子集(400/500/700 各一份):三份静态子集合计 ≈25 MiB,
|
||||
/// 而可变字体一份 17 MiB 就覆盖 100–900(cosmic-text 0.15 会把请求的字重写进 `wght` 轴,见
|
||||
/// 而可变字体一份 16.9 MiB 就覆盖 100–900(cosmic-text 0.15 会把请求的字重写进 `wght` 轴,见
|
||||
/// `crates/core/src/download/font.rs` 模块文档与 M168 报告里的「抓不住」一段)。
|
||||
pub const NOTO_SANS_SC: CjkFont = CjkFont {
|
||||
family: "Noto Sans SC",
|
||||
|
||||
Reference in new issue
Block a user