fix(m168): 复审 r2 三项 —— 夹具不再空转、启动不再替英文用户下载、时限不再判慢线下不完

复审 r2(p2-3 / fix-then-merge)三项:

1. **P2-1 爬行服务端是空转的**(`font/tests/support.rs`,从 `tests.rs` 拆出守 400 行):
   `seen.windows(4).any(|w| w == b"\n\n")` 拿 4 字节窗口比 2 字节切片 ⇒ `PartialEq` 先比长度 ⇒
   恒 false ⇒ 请求头永远读不完、一个字节不发,用例证明的其实只是「连接挂着被掐断」。上一版
   这里是 Python 生成时把 `\r\n` 落成了真实换行字节。现在两处都是转义写法
   (`b"\r\n\r\n"` + 完整 CRLF 响应头),并且**把「夹具真的在说话」变成断言**:
   `serve_trickle()` 返回 `TrickleProof { header, dripped }`,用例断言「真的发过头」「真的挤过
   字节」——否则夹具再坏一次也会绿。拆成两条用例:`a_trickling_candidate_is_cut_by_the_attempt_deadline`
   ([爬行, 正常],300 ms 切断第一个后第二个成功,0.38 s)与
   `the_total_deadline_cuts_a_single_crawling_candidate`(单候选 + 400 ms 总预算)。
   变异证明:把服务端改成「一个字节都不发」⇒ 两条用例红在「爬行服务端必须真的发出响应头」。

2. **P2-2 启动替英文用户下 17 MiB(真行为 bug)**:
   - `fonts::resolve_with` **不再触发下载**(启动那一拍只报告判据)。唯一的触发点收在
     `fonts::cjk_for`(`ui/lang.rs::log_cjk`),那一拍的 `setting` 与 `LangChoice` 同源
     (ULL_LANG > 配置 > 系统 locale)= 「界面实际用哪种语言」。
   - `plan` 把 `is_downloading()` / `Ready` 的闸提到**所有策略之前**:上一版只在 `NeedsDownload`
     分支里,"下载中 + Unprobeable" 会把状态改回「无法探测」而线程还在飞,下完又翻回「已就绪」。
     当时那条理由(「否则界面停在正在下载」)只对僵死成立 ⇒ worker 现在用 `catch_unwind`
     兜底「任何 panic 都写终态」,闸才敢前移。
   - 出图通道:新增 `install::set_offline(true)`(`App::with_presenter` 在
     `options.screenshot.is_some()` 时调),`plan_with_offline` 在 offline 下永不 `Start`。
     于是 `pages/harness/app.rs` 与 `--screenshot` 都不会在 zh-locale 无字体机器上真下 17 MiB。
   - 被钉住的旧判据(`Unprobeable + Downloading ⇒ Apply`)改成 `Skip` 并写明旧行为为什么是错的;
     新增 `every_strategy_skips_while_a_download_is_in_flight`(四策略打表)与
     `the_screenshot_channel_never_starts_a_download`。

3. **P2-3 90 s 单次上限把「慢但能下完」变永久失败**:改 `ATTEMPT_DEADLINE=120 s` /
   `TOTAL_DEADLINE=360 s`,并且**最后一个候选拿整个剩余总预算**(前 n-1 个仍 `min(单次, 剩余)`)。
   算账写在常量文档里:17 772 300 B / 360 s ≈ 49 KB/s,比 r1 实测最慢那条 IPv6(67~77 KB/s)
   还低一档 ⇒ 慢而能下完的线路仍有一次成功机会;健康线路(1.1~16.6 s)在第一/第二个候选就结束。
   代价(最坏 6 分钟)也是刻意取舍,写在同一处。

顺手:`i18n::CjkFont::source` 的「尚未钉死」改成指向 `Source::Pinned`;`clock.rs` 与
`general_section.rs` 注释里的 17.0 MB → 16.9 MB。

验证(`CARGO_TARGET_DIR=C:/Temp/ull-target/wt-168`,全量重编;取退出码,不截断):
- `cargo test -p unlucky-core --all-targets` → **16 个 target / 1189 passed / 0 failed / exit 0**
  (lib 625 + setting 115 + 其余 14 个)
- `cargo test -p unlucky-i18n` → 31 passed / 0 failed / exit 0
- `cargo test -p unlucky-app-iced`(含 `no_hardcoded_tokens` integration)→ **693 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(本分支最大 361)
This commit is contained in:
AskaEth committed 2026-11-08 14:38:25 +08:00
1 parent 875b792206
commit b9009c0e61
11 files changed
+440 -199

No files matched your search

+73 -158
View File
@@ -6,108 +6,20 @@
//! 外网站点,跑 `cargo test` 不需要网络。真 CDN 的字节有没有变、17 MiB 要下多久,这些**不在**
//! 本文件的能力范围内(报告里写明)。
use std::io::{Read as _, Write as _};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::path::Path;
use std::sync::atomic::Ordering;
use std::time::Duration;
use super::super::types::HashAlgo;
use super::*;
/// 每个用例一个独立数据根(共享引擎的登记处是进程级的,键撞了会互相干扰)。
fn root(tag: &str) -> PathBuf {
static SEQ: AtomicUsize = AtomicUsize::new(0);
let seq = SEQ.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("ull-font-{tag}-{}-{seq}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建数据根");
dir
}
#[path = "tests/support.rs"]
mod support;
/// 一段「像字体」的字节:sfnt 头 + 一张表记录 + 载荷。
///
/// 为什么造它而不是塞随机字节:`looks_like_sfnt` 是落盘前的一道闸,测试若拿 HTML 当正常内容,
/// 「下载成功」这条路径就永远走不到。
fn fake_font(payload: &[u8]) -> Vec<u8> {
let mut bytes = Vec::new();
bytes.extend_from_slice(b"\x00\x01\x00\x00"); // sfnt 版本
bytes.extend_from_slice(&1u16.to_be_bytes()); // numTables
bytes.extend_from_slice(&[0u8; 6]); // searchRange / entrySelector / rangeShift
bytes.extend_from_slice(b"TEST"); // 表名
bytes.extend_from_slice(&0u32.to_be_bytes()); // checksum
bytes.extend_from_slice(&28u32.to_be_bytes()); // offset
bytes.extend_from_slice(&(payload.len() as u32).to_be_bytes());
bytes.extend_from_slice(payload);
bytes
}
fn sha256(bytes: &[u8]) -> String {
verify::digest_bytes(HashAlgo::Sha256, bytes)
}
/// 回环 HTTP 服务:不管请求什么,都回同一份字节;记下处理了几条请求。
///
/// 只讲 HTTP/1.1 keep-alive,够用来判「第二次调用有没有再发请求」。
fn serve(body: Vec<u8>, expected_requests: usize) -> (String, Arc<AtomicUsize>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("绑定回环端口");
let addr = listener.local_addr().expect("取端口");
let served = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&served);
std::thread::spawn(move || {
let mut requests = 0usize;
while requests < expected_requests {
let Ok((mut socket, _)) = listener.accept() else {
break;
};
let _ = socket.set_read_timeout(Some(Duration::from_secs(10)));
let mut seen = Vec::new();
loop {
let mut chunk = [0u8; 1024];
match socket.read(&mut chunk) {
Ok(0) | Err(_) => break,
Ok(read) => seen.extend_from_slice(&chunk[..read]),
}
if !seen.windows(4).any(|window| window == b"\r\n\r\n") {
continue;
}
requests += 1;
counter.store(requests, Ordering::SeqCst);
let head = format!(
"HTTP/1.1 200 OK\r\nContent-Type: font/ttf\r\nContent-Length: {}\r\n\r\n",
body.len()
);
if socket.write_all(head.as_bytes()).is_err() || socket.write_all(&body).is_err() {
break;
}
seen.clear();
if requests >= expected_requests {
return;
}
}
}
});
(format!("http://{addr}/cjk.ttf"), served)
}
/// 一个**不会**有人监听的地址:证明「没联网」比证明「联网成功」更有力。
fn dead_url() -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("占一个端口");
let addr = listener.local_addr().expect("取端口");
drop(listener);
format!("http://{addr}/cjk.ttf")
}
fn spec_for(urls: Vec<String>, bytes: &[u8]) -> FontSpec {
FontSpec::new(urls, sha256(bytes), bytes.len() as u64).expect("pin 应自洽")
}
use support::{dead_url, fake_font, root, serve, serve_trickle, sha256, spec_for};
/// 守卫 ③:**缓存命中不联网**。
///
/// 抓得住:把 `fetch_blocking` 开头的缓存分支删掉(或改成「只 stat 不校验」以外的任何短路取消)
/// —— 那样 URL 指向的死人端口会被真的去连,本用例当场红。
/// 抓得住:把 `fetch_blocking` 开头的缓存分支删掉——URL 指向的死人端口会被真的去连,本用例红。
/// 抓不住:真机上 17 MiB 文件读盘有多快。
#[test]
fn cache_hit_never_touches_the_network() {
@@ -223,79 +135,78 @@ fn an_html_error_page_is_not_a_font() {
std::fs::remove_dir_all(&dir).ok();
}
/// 爬行服务端:回完头之后每 100 ms 只挤 1 字节,**永不结束**。
/// 守卫(M168 复审 r2 P2-1/P2-3):**单次尝试的时限会打断爬行连接并转下一个候选**。
///
/// 这正是复审 P2-2 实测到的那条路径(IPv6 边缘 ≈67 KB/s,60 s 只到 4 MB):引擎的
/// `read_timeout` 是 30 s **空闲**超时,字节一直在动 ⇒ 引擎自己永远不会判它失败。只有我们
/// 那一层的整体时限能打断它。离线(回环)可跑。
fn serve_trickle() -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("绑定回环端口");
let addr = listener.local_addr().expect("取端口");
std::thread::spawn(move || {
let Ok((mut socket, _)) = listener.accept() else {
return;
};
let _ = socket.set_read_timeout(Some(Duration::from_secs(5)));
// 读掉请求头(不回也行,但要先把请求收干净,否则某些栈会说 RST)。
let mut seen = Vec::new();
loop {
let mut chunk = [0u8; 1024];
match socket.read(&mut chunk) {
Ok(0) | Err(_) => return,
Ok(read) => seen.extend_from_slice(&chunk[..read]),
}
if seen.windows(4).any(|window| {
window
== b"
"
}) {
break;
}
}
// 声明一个够大的长度,然后开始「爬」:每 100 ms 一个字节。
if socket
.write_all(
b"HTTP/1.1 200 OK
Content-Type: font/ttf
Content-Length: 1000000
",
)
.is_err()
{
return;
}
for _ in 0..600 {
if socket.write_all(&[0u8]).is_err() {
return;
}
std::thread::sleep(Duration::from_millis(100));
}
});
format!("http://{addr}/cjk.ttf")
}
/// 守卫(M168 复审 P2-2):**只爬不死的连接由整体时限打断**,不许让界面干等。
/// 这个场景现在是真的:第一个候选是 `serve_trickle()`(发真实 CRLF 响应头,之后每 100 ms 一个
/// 字节),第二个候选是正常服务端。
///
/// 抓得住:把 `tokio::time::timeout` 摘掉(只剩引擎的 30 s **空闲**超时)——本用例会一直
/// 跑在「每 100 ms 一个字节」的连接上,`started.elapsed() < 4 s` 那条断言直接红(实际会挂到
/// 测试框架超时)。也抓得住「超时被归成校验失败」:`class()` 必须还是 `network`。
/// 抓不住:90 s/180 s 两个默认值在真网络下够不够(那要真 CDN,见报告「未验」)。
/// 抓得住:①`serve_trickle` 的响应头写错(上一版就是这个病:真实换行字节 + 4 字节窗口 ⇒ 恒 false
/// ⇒ 一个字节都不发)——那样第一个候选会在**读不到响应头**时按网络错失败,用例里
/// `trickle_served` 的「服务端真的发过头」这条断言会红;②`budget` 不是 `min(单次, 剩余)` 而是
/// 直接用总预算 ⇒ 本用例会等到总预算(5 s)才换源,`elapsed < 3 s` 红;③时限被归成校验失败。
/// 抓不住:默认值(120 s / 360 s)在真弱网下够不够(那要真 CDN,见报告「未验」)。
#[test]
fn a_trickling_connection_is_cut_by_the_deadline() {
let dir = root("deadline");
let body = fake_font(b"not what the server sends");
let spec = FontSpec::new(vec![serve_trickle()], sha256(&body), body.len() as u64).expect("pin");
fn a_trickling_candidate_is_cut_by_the_attempt_deadline() {
let dir = root("deadline-attempt");
let good = fake_font(b"served by the second candidate");
let (good_url, _served) = serve(good.clone(), 4);
let (trickle_url, proof) = serve_trickle();
let spec = FontSpec::new(
vec![trickle_url, good_url],
sha256(&good),
good.len() as u64,
)
.expect("pin");
let started = std::time::Instant::now();
let error = fetch_blocking_with_limits(
let fetched = fetch_blocking_with_limits(
&dir,
&spec,
Duration::from_millis(300),
Duration::from_secs(5),
)
.expect_err("爬行连接必须被时限打断");
.expect("第二个候选应当成功");
assert_eq!(fetched.bytes, good);
assert!(
started.elapsed() < Duration::from_secs(3),
"单次上限该在 300 ms 处切断第一个候选,实际 {:?}",
started.elapsed()
);
assert!(cache_path(&dir, &spec).exists());
// **这条是「夹具没坏」的判据**(复审 r2 P2-1):第一个候选必须真的发过响应头、真的挤过字节。
// 少了这两条,一个「什么都不发的服务端」也能让本用例绿——那证明的是「连接挂着被掐断」,
// 而不是我们想证明的「活着但在爬」。
assert!(
proof.header.load(Ordering::SeqCst),
"爬行服务端必须真的发出响应头"
);
assert!(
proof.dripped.load(Ordering::SeqCst) >= 1,
"爬行服务端必须真的挤出过字节(否则引擎的空闲超时那条路径根本没被造出来)"
);
std::fs::remove_dir_all(&dir).ok();
}
/// 守卫(M168 复审 r2 P2-3):**总时限**也会打断爬行,且文案说清「超时」、不落盘。
///
/// 抓得住:总预算被当成「每个候选各给一次」⇒ 本用例会跑到 600 × 100 ms 的爬行结束(≈60 s),
/// `elapsed < 4 s` 红;也抓得住超时被归成其它类别(`class()` 必须是 `network`)。
/// 抓不住:真网络上的取值合不合适(同上,见「未验」)。
#[test]
fn the_total_deadline_cuts_a_single_crawling_candidate() {
let dir = root("deadline-total");
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");
let started = std::time::Instant::now();
let error = fetch_blocking_with_limits(
&dir,
&spec,
Duration::from_secs(30),
Duration::from_millis(400),
)
.expect_err("总时限必须打断爬行");
assert_eq!(error.class(), "network", "超时属于网络档:{error}");
assert!(
error.message().contains("超时"),
@@ -307,6 +218,10 @@ fn a_trickling_connection_is_cut_by_the_deadline() {
started.elapsed()
);
assert!(!cache_path(&dir, &spec).exists(), "超时不许落盘");
assert!(
proof.dripped.load(Ordering::SeqCst) >= 1,
"总时限也该掐在「有字节在动」的连接上,而不是一个不响的端口"
);
std::fs::remove_dir_all(&dir).ok();
}
@@ -0,0 +1,177 @@
//! `download::font` 的测试夹具(回环 HTTP 服务端 / 假字体字节 / 临时数据根)。
//!
//! 从 `tests.rs` 拆出只为守 400 行硬线(复审 r2 加了两条时限用例之后越线)。这里只有夹具,
//! 断言全在 `tests.rs`——两个回环服务端(`serve` 正常、`serve_trickle` 只爬)是「缓存/校验/时限」
//! 三组守卫的地基,改坏它们等于把守卫变成空转(复审 r2 P2-1 就是这么发现上一版的
//! CRLF 字面量写坏了的)。
use std::io::{Read as _, Write as _};
use std::net::TcpListener;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use super::super::super::verify;
use super::super::{FontSpec, HashAlgo};
/// 每个用例一个独立数据根(共享引擎的登记处是进程级的,键撞了会互相干扰)。
pub(super) fn root(tag: &str) -> PathBuf {
static SEQ: AtomicUsize = AtomicUsize::new(0);
let seq = SEQ.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("ull-font-{tag}-{}-{seq}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建数据根");
dir
}
/// 一段「像字体」的字节:sfnt 头 + 一张表记录 + 载荷。
///
/// 为什么造它而不是塞随机字节:`looks_like_sfnt` 是落盘前的一道闸,测试若拿 HTML 当正常内容,
/// 「下载成功」这条路径就永远走不到。
pub(super) fn fake_font(payload: &[u8]) -> Vec<u8> {
let mut bytes = Vec::new();
bytes.extend_from_slice(b"\x00\x01\x00\x00"); // sfnt 版本
bytes.extend_from_slice(&1u16.to_be_bytes()); // numTables
bytes.extend_from_slice(&[0u8; 6]); // searchRange / entrySelector / rangeShift
bytes.extend_from_slice(b"TEST"); // 表名
bytes.extend_from_slice(&0u32.to_be_bytes()); // checksum
bytes.extend_from_slice(&28u32.to_be_bytes()); // offset
bytes.extend_from_slice(&(payload.len() as u32).to_be_bytes());
bytes.extend_from_slice(payload);
bytes
}
pub(super) fn sha256(bytes: &[u8]) -> String {
verify::digest_bytes(HashAlgo::Sha256, bytes)
}
/// 回环 HTTP 服务:不管请求什么,都回同一份字节;记下处理了几条请求。
///
/// 只讲 HTTP/1.1 keep-alive,够用来判「第二次调用有没有再发请求」。
pub(super) fn serve(body: Vec<u8>, expected_requests: usize) -> (String, Arc<AtomicUsize>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("绑定回环端口");
let addr = listener.local_addr().expect("取端口");
let served = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&served);
std::thread::spawn(move || {
let mut requests = 0usize;
while requests < expected_requests {
let Ok((mut socket, _)) = listener.accept() else {
break;
};
let _ = socket.set_read_timeout(Some(Duration::from_secs(10)));
let mut seen = Vec::new();
loop {
let mut chunk = [0u8; 1024];
match socket.read(&mut chunk) {
Ok(0) | Err(_) => break,
Ok(read) => seen.extend_from_slice(&chunk[..read]),
}
if !seen.windows(4).any(|window| window == b"\r\n\r\n") {
continue;
}
requests += 1;
counter.store(requests, Ordering::SeqCst);
let head = format!(
"HTTP/1.1 200 OK\r\nContent-Type: font/ttf\r\nContent-Length: {}\r\n\r\n",
body.len()
);
if socket.write_all(head.as_bytes()).is_err() || socket.write_all(&body).is_err() {
break;
}
seen.clear();
if requests >= expected_requests {
return;
}
}
}
});
(format!("http://{addr}/cjk.ttf"), served)
}
/// 一个**不会**有人监听的地址:证明「没联网」比证明「联网成功」更有力。
pub(super) fn dead_url() -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("占一个端口");
let addr = listener.local_addr().expect("取端口");
drop(listener);
format!("http://{addr}/cjk.ttf")
}
/// 爬行服务端的取证计数器:`header` = 真的发过响应头,`dripped` = 真的挤出过几个字节。
///
/// 为什么把这两个数字**带出去**:这类 `serve_*` 夹具坏掉时,用例会静默变成另一种场景
/// (复审 r2 P2-1 就是:请求头永远读不完 ⇒ 一个字节不发 ⇒ 用例证明的是「连接挂着」,而不是
/// 「活着但在爬」)。用例拿到这两个数就能断言「服务端确实在说话」。
pub(super) struct TrickleProof {
/// 响应头是否真的写出去了。
pub header: Arc<std::sync::atomic::AtomicBool>,
/// 写出去了几个正文字节。
pub dripped: Arc<AtomicUsize>,
}
/// 由 URL 候选 + 期望字节算 pin(哈希取这批字节的 sha256)。
pub(super) fn spec_for(urls: Vec<String>, bytes: &[u8]) -> FontSpec {
FontSpec::new(urls, sha256(bytes), bytes.len() as u64).expect("pin 应自洽")
}
/// 爬行服务端:回完头之后每 100 ms 只挤 1 字节,**永远不结束**。
///
/// 这就是复审 r1 P2-2 实测到的那条路径(Fastly 的 IPv6 边缘 ≈67 KB/s):引擎的 `read_timeout`
/// 是 30 s **空闲**超时,而字节一直在动 ⇒ 引擎自己永远不会判它失败。只有我们这一层的整体时限
/// 能打断它——这条服务端存在的唯一目的就是造出「活着但爬」的连接。
///
/// 注意这里的 `\r\n` 必须是**转义写法**:上一版这个函数里写成了真实换行字节(`b"\n\n"`,2 字节)
/// 却拿 4 字节窗口去比,`PartialEq` 先比长度 ⇒ 恒 false ⇒ 请求头永远读不完、一个字节都不发,
/// 用例证明的其实是另一件事(复审 r2 P2-1)。`serve()` 那边的写法是对的,别动它。
pub(super) fn serve_trickle() -> (String, TrickleProof) {
let header_sent = Arc::new(std::sync::atomic::AtomicBool::new(false));
let dripped = Arc::new(AtomicUsize::new(0));
let (header_flag, drip_flag) = (Arc::clone(&header_sent), Arc::clone(&dripped));
let listener = TcpListener::bind("127.0.0.1:0").expect("绑定回环端口");
let addr = listener.local_addr().expect("取端口");
std::thread::spawn(move || {
let Ok((mut socket, _)) = listener.accept() else {
return;
};
let _ = socket.set_read_timeout(Some(Duration::from_secs(5)));
// 先把请求头读干净(不读完就写响应,某些栈会 RST)。
let mut seen = Vec::new();
loop {
let mut chunk = [0u8; 1024];
match socket.read(&mut chunk) {
Ok(0) | Err(_) => return,
Ok(read) => seen.extend_from_slice(&chunk[..read]),
}
if seen.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
// 声明一个够大的长度,然后开始「爬」:每 100 ms 一个字节。
if socket
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Type: font/ttf\r\nContent-Length: 1000000\r\n\r\n",
)
.is_err()
{
return;
}
header_flag.store(true, Ordering::SeqCst);
for _ in 0..600 {
if socket.write_all(&[0u8]).is_err() {
return;
}
drip_flag.fetch_add(1, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(100));
}
});
(
format!("http://{addr}/cjk.ttf"),
TrickleProof {
header: header_sent,
dripped,
},
)
}