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

+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();
}