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

+23 -16
View File
@@ -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 的用例没人会跑。
+32 -16
View File
@@ -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();
}