From b9009c0e619e2b32db2f77b193ba32ca9ae82e62 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 13:42:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(m168):=20=E5=A4=8D=E5=AE=A1=20r2=20?= =?UTF-8?q?=E4=B8=89=E9=A1=B9=20=E2=80=94=E2=80=94=20=E5=A4=B9=E5=85=B7?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E7=A9=BA=E8=BD=AC=E3=80=81=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=9B=BF=E8=8B=B1=E6=96=87=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E3=80=81=E6=97=B6=E9=99=90=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=88=A4=E6=85=A2=E7=BA=BF=E4=B8=8B=E4=B8=8D=E5=AE=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 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) --- crates/app-iced/src/app/state/construct.rs | 4 + crates/app-iced/src/app/update/clock.rs | 2 +- crates/app-iced/src/fonts.rs | 19 +- crates/app-iced/src/fonts/install.rs | 66 +++-- crates/app-iced/src/fonts/install/tests.rs | 69 +++++- crates/app-iced/src/fonts/install/worker.rs | 22 +- .../pages/settings/tests/general_section.rs | 2 +- crates/core/src/download/font.rs | 45 +++- crates/core/src/download/font/tests.rs | 231 ++++++------------ .../core/src/download/font/tests/support.rs | 177 ++++++++++++++ crates/i18n/src/font.rs | 2 +- 11 files changed, 440 insertions(+), 199 deletions(-) create mode 100644 crates/core/src/download/font/tests/support.rs diff --git a/crates/app-iced/src/app/state/construct.rs b/crates/app-iced/src/app/state/construct.rs index 3c1ae37..3d9d837 100644 --- a/crates/app-iced/src/app/state/construct.rs +++ b/crates/app-iced/src/app/state/construct.rs @@ -86,6 +86,10 @@ impl App { // 界面语言(M106):**M92 已经探测对了,问题是一直没人用**——`pages::Model.lang` 此前在 // `view::model` 里被写死成 `Lang::En`,于是中文系统上开出来的是全英文界面。 let lang = LangState::boot(&bridge, options.screenshot.is_some()); + // M168(复审 r2 P2-2):出图通道**不许联网**——`log_cjk` 那一拍会走 `after_probe`, + // 一台 zh-locale 且没有中文字体的机器上会真的下 17 MiB,并可能在出图过程中注册新字体 + // (D30:同一份代码要出同一张图)。产品路径不设这个开关。 + crate::fonts::install::set_offline(options.screenshot.is_some()); let mut view = pages::Model::new( pages::Screen::Instances, width, diff --git a/crates/app-iced/src/app/update/clock.rs b/crates/app-iced/src/app/update/clock.rs index afa37a6..03cb0e5 100644 --- a/crates/app-iced/src/app/update/clock.rs +++ b/crates/app-iced/src/app/update/clock.rs @@ -43,7 +43,7 @@ impl App { self.view_dirty = true; } // 中文字体下载(M168):后台线程推进,这里只比对快照。变了就重建视图模型 ⇒ 设置页那一行 - // 从「正在获取中文字体…(17.0 MB)」翻到「已就绪 / 获取失败」,不需要用户再点一下。 + // 从「正在获取中文字体…(16.9 MB)」翻到「已就绪 / 获取失败」,不需要用户再点一下。 let font_status = crate::fonts::install::status(); if font_status != self.font_seen { self.font_seen = font_status; diff --git a/crates/app-iced/src/fonts.rs b/crates/app-iced/src/fonts.rs index de3985b..d89c794 100644 --- a/crates/app-iced/src/fonts.rs +++ b/crates/app-iced/src/fonts.rs @@ -224,8 +224,10 @@ pub fn resolve_with(setting: Option<&str>) -> Resolved { // D28 后半:语言 + 中文环境的中文字体策略。`faces` 的 `None` 在这里是「字体库读不出来」, // 适配层会把它翻成 `Unprobeable` 而不是「缺字体」(否则会误触发一次 CDN 下载)。 let cjk_report = cjk::probe_with(setting, faces.as_deref()); - // M168:只有 `NeedsDownload` 才会在这里发起下载(后台线程),其余三路一次网络都不发。 - install::after_probe(cjk_report.strategy); + // **这里不发起下载**(复审 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`,那一拍已经知道**生效语言**)。 Resolved { platform, @@ -235,13 +237,18 @@ pub fn resolve_with(setting: Option<&str>) -> Resolved { } } -/// 语言切换后的中文策略重算(M106 判据 / M168 触发下载):`setting` 是配置里 `ui.language` 的 -/// **显式值**(`None` = 跟随系统,由 [`crate::platform::locale::SystemLocale`] 决定)。 +/// 语言切换后的中文策略重算(M106 判据)+ **唯一**的下载触发点(M168):`setting` 是配置里 +/// `ui.language` 的**显式值**(`None` = 跟随系统,由 [`crate::platform::locale::SystemLocale`] 决定)。 /// /// 为什么需要它:启动那一轮([`resolve`])拿不到配置,只按系统 locale 判。用户在英文系统上 /// 手选「简体中文」时,若不复算,策略会停在 `NotNeeded`——界面文字是中文、字体却没有中文字形, -/// 那就是白块。复算出的 `NeedsDownload` 现在**真的会去下载**(M168:`install::after_probe`), -/// 结果进设置页那一行(`pages::settings::general`)与 stderr 的 `[font] cjk-download …` 日志。 +/// 那就是白块。 +/// +/// **为什么触发只留在这里**(复审 r2 P2-2 ①):这一拍的 `setting` 与 `LangChoice` 同源 +/// (`ULL_LANG` > 配置 > 系统 locale),也就是「界面实际用哪种语言」的那一份判据。 +/// 放在 [`resolve`] 里就会用「系统 locale」代表「用户选择」,zh-locale + 配置 `en_us` 的机器 +/// 会白下一次 17 MiB。出图通道(`--screenshot`)由 `install::set_offline(true)` 兜住, +/// 仍然一次都不发。 pub fn cjk_for(setting: Option<&str>) -> CjkReport { let report = cjk::probe_with(setting, system_faces().as_deref()); install::after_probe(report.strategy); diff --git a/crates/app-iced/src/fonts/install.rs b/crates/app-iced/src/fonts/install.rs index 7a6d1b5..71f8540 100644 --- a/crates/app-iced/src/fonts/install.rs +++ b/crates/app-iced/src/fonts/install.rs @@ -250,34 +250,54 @@ pub enum Step { /// 判据表(判据本体就在这张表的 `match` 里,`after_probe` 不含任何判据)。 /// -/// | 策略 | 当前状态 | 这一步 | +/// | 当前状态 | 策略 | 这一步 | /// |---|---|---| -/// | `NotNeeded` / `System` / `Unprobeable` | 任意 | `Apply`(零网络) | -/// | `NeedsDownload` | 下载中 / 已就绪 | **`Skip`**(幂等:切一次语言不起第二条线程) | -/// | `NeedsDownload` | 未判定 / 失败过 / 其它 | `Start`(失败后的重试只能是用户再切一次语言) | +/// | **下载中**(任意策略) | 任意 | **`Skip`**(闸在最上面,见下) | +/// | `Ready` | 任意 | `Skip`(幂等:已就绪不再发起) | +/// | 其它 | `NotNeeded` / `System` / `Unprobeable` | `Apply`(只改状态,零网络) | +/// | 其它 | `NeedsDownload` | `Start`(失败过的重试只能由用户再切一次语言触发) | +/// +/// ## 为什么「下载中」要在**所有**策略之前先跳过(复审 r2 P2-2 ②) +/// +/// 上一版把 `is_downloading()` 只放在 `NeedsDownload` 分支里,于是「下载中 + `Unprobeable`」 +/// 会走 `Apply(Unprobeable)` —— 把状态从「正在下载」改回「无法探测」,而**线程还在飞**: +/// 下完之后 `Ready` 又把它盖回去,状态行在 30 秒里来回翻。当时给出的理由是「否则界面会停在 +/// 正在下载」——那个理由只对**僵死**的 `Downloading` 成立(线程 panic 才会僵死),而在飞是 +/// 常见情形。僵死那一半现在由 worker 的 `catch_unwind` 兜底(任何 panic 都会写终态), +/// 所以「下载中一律 Skip」不再有卡死的风险。 +/// +/// `offline = true`(出图通道)时**永不** `Start`:D30 要求同一份代码出同一张图,而下载会额外 +/// 产生网络流量并可能在出图过程中注册新字体。 #[must_use] -pub fn plan(strategy: Strategy, current: &CjkInstall) -> Step { +pub fn plan_with_offline(strategy: Strategy, current: &CjkInstall, offline: bool) -> Step { + if current.is_downloading() || matches!(current, CjkInstall::Ready { .. }) { + return Step::Skip; + } match strategy { Strategy::NotNeeded => Step::Apply(CjkInstall::NotNeeded), Strategy::System { family } => Step::Apply(CjkInstall::System { family: family.to_owned(), }), Strategy::Unprobeable => Step::Apply(CjkInstall::Unprobeable), - Strategy::NeedsDownload(font) => { - if current.is_downloading() || matches!(current, CjkInstall::Ready { .. }) { - Step::Skip - } else { - Step::Start(font) - } - } + // 出图通道需要下载 ⇒ **什么都不做**(也不改状态):写「不需要下载」是谎话, + // 写「正在下载」也是谎话(并没有在下载)。 + Strategy::NeedsDownload(_) if offline => Step::Skip, + Strategy::NeedsDownload(font) => Step::Start(font), } } +/// 同 [`plan_with_offline`],但按本进程当前的联网策略(生产判据入口)。 +#[must_use] +pub fn plan(strategy: Strategy, current: &CjkInstall) -> Step { + plan_with_offline(strategy, current, offline()) +} + /// 判据结论 → 现在这一步的结局(生产入口;判据全在 [`plan`],这里只执行)。 /// -/// * 英文环境 / 系统已有 / 探测不出 → 只更新状态(零网络); /// * 已经在下载 / 已就绪 → 什么都不做(幂等:切一次语言不会起第二条下载、不会重复注册 17 MiB); -/// * 上次失败过 → 只有再次切语言才会重试(用户显式动作),不会自己反复联网。 +/// * 英文环境 / 系统已有 / 探测不出 → 只更新状态(零网络); +/// * 上次失败过 → 只有再次切语言才会重试(用户显式动作),不会自己反复联网; +/// * 出图通道([`set_offline`])→ 永不发起。 pub fn after_probe(strategy: Strategy) -> CjkInstall { match plan(strategy, &status()) { Step::Skip => status(), @@ -292,6 +312,24 @@ pub fn after_probe(strategy: Strategy) -> CjkInstall { } } +/// 本进程是否**禁止**发起字体下载(出图通道用;D30:同一份代码出同一张图)。 +/// +/// 为什么需要它:截图/演示通道也会走 `App` 构造 → `lang.log_cjk()` → `cjk_for` → `after_probe`。 +/// 一台 zh-locale、没有中文字体的机器上,出图会真的下 17 MiB 并在出图过程中注册新字体—— +/// 这既产生流量,也让「同一份代码出同一张图」不再成立。 +pub fn set_offline(offline: bool) { + OFFLINE.store(offline, std::sync::atomic::Ordering::Relaxed); +} + +/// 当前是否处于 [`set_offline`] 的状态。 +#[must_use] +pub fn offline() -> bool { + OFFLINE.load(std::sync::atomic::Ordering::Relaxed) +} + +/// 进程级联网开关(默认允许)。 +static OFFLINE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + /// 状态变更时记一行(只有真变了才打,避免每秒刷一行)。 pub(super) fn update(state: CjkInstall) { let previous = { diff --git a/crates/app-iced/src/fonts/install/tests.rs b/crates/app-iced/src/fonts/install/tests.rs index 340b1ee..a8b9728 100644 --- a/crates/app-iced/src/fonts/install/tests.rs +++ b/crates/app-iced/src/fonts/install/tests.rs @@ -218,13 +218,78 @@ fn the_idle_strategies_only_apply_state() { family: "SimSun".to_owned() }) ); + // 复审 r2 P2-2 ② 改判:**下载中一律 Skip**,即使策略说「探测不出」。 + // 上一版在这里断言 `Apply(Unprobeable)`,理由是「否则界面会停在正在下载」——那个理由只对 + // 僵死成立(线程 panic),而在飞是常见情形:线程还在下,状态却被改成「无法探测」,下完又 + // 翻回「已就绪」,状态行在几十秒里来回翻。僵死由 worker 的 catch_unwind 兜底。 assert_eq!( plan(Strategy::Unprobeable, &CjkInstall::Downloading { bytes: 1 }), - Step::Apply(CjkInstall::Unprobeable), - "探测不出也要把状态改回来(否则界面会停在「正在下载」)" + Step::Skip, + "在飞的下载不许被别的策略改写状态" ); } +/// **下载中一律 `Skip`**(复审 r2 P2-2 ②):四种策略挨个打一遍。 +/// +/// 抓得住:把 `is_downloading()` 那道闸挪回 `NeedsDownload` 分支里(上一版就是这样)—— +/// `Unprobeable` / `NotNeeded` / `System` 三行立刻红。 +/// 抓不住:worker 真的永不僵死(那要制造 panic,见 `worker` 的 `catch_unwind` 与报告的「没验」)。 +#[test] +fn every_strategy_skips_while_a_download_is_in_flight() { + let downloading = CjkInstall::Downloading { bytes: 17_772_300 }; + for strategy in [ + Strategy::NeedsDownload(&NOTO_SANS_SC), + Strategy::NotNeeded, + Strategy::System { family: "SimSun" }, + Strategy::Unprobeable, + ] { + assert_eq!( + plan(strategy, &downloading), + Step::Skip, + "{strategy:?}:下载中不许改状态、不许起第二条线程" + ); + } +} + +/// 出图通道(`offline`)**永不发起下载**(D30:同一份代码出同一张图)。 +/// +/// 抓得住:删掉 `plan_with_offline` 里 `NeedsDownload(_) if offline => Skip` 那一臂 ⇒ 截图/演示 +/// 通道会在 zh-locale 且无中文字体的机器上真下 17 MiB,用例在 `Step::Skip` 处红。 +/// 抓不住:真出图时有没有产生流量(那要真机跑 `--screenshot`,见报告「没验」)。 +#[test] +fn the_screenshot_channel_never_starts_a_download() { + assert_eq!( + plan_with_offline( + Strategy::NeedsDownload(&NOTO_SANS_SC), + &CjkInstall::Unknown, + true + ), + Step::Skip, + "出图通道不许联网" + ); + assert_eq!( + plan_with_offline( + Strategy::NeedsDownload(&NOTO_SANS_SC), + &CjkInstall::Unknown, + false + ), + Step::Start(&NOTO_SANS_SC), + "正常通道该发起(否则上面那条可能是「永远 Skip」的假绿)" + ); + // 只改状态的三种策略在两条通道里表现一致(它们本来就不联网)。 + for strategy in [ + Strategy::NotNeeded, + Strategy::System { family: "SimSun" }, + Strategy::Unprobeable, + ] { + assert_eq!( + plan_with_offline(strategy, &CjkInstall::Unknown, false), + plan_with_offline(strategy, &CjkInstall::Unknown, true), + "{strategy:?}:不联网的策略与通道无关" + ); + } +} + /// 截图钉子(D30):每个 `ULL_FONT_STATUS` 取值都能解析成一档状态,坏值不猜。 /// /// 抓得住:出图夹具写了一个不存在的取值却静默出「未知」状态的图(报告就会撒谎)。 diff --git a/crates/app-iced/src/fonts/install/worker.rs b/crates/app-iced/src/fonts/install/worker.rs index d2d647b..31028fa 100644 --- a/crates/app-iced/src/fonts/install/worker.rs +++ b/crates/app-iced/src/fonts/install/worker.rs @@ -68,11 +68,23 @@ pub(super) fn start(font: &'static font::CjkFont) { // 判据说「需要」,这条线程才存在:不需要的三种情形在 `after_probe` 就返回了。 std::thread::spawn(move || { - let state = ensure_for( - Strategy::NeedsDownload(font), - &fetcher, - ®ister_into_font_system, - ); + // **保证写终态**(复审 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); diff --git a/crates/app-iced/src/pages/settings/tests/general_section.rs b/crates/app-iced/src/pages/settings/tests/general_section.rs index 0afa64a..5a49fef 100644 --- a/crates/app-iced/src/pages/settings/tests/general_section.rs +++ b/crates/app-iced/src/pages/settings/tests/general_section.rs @@ -102,7 +102,7 @@ fn the_font_status_row_speaks_in_four_states_and_two_languages() { assert!(value.contains(reason), "失败原因没进文案:{value}"); } - // 下载中写的是**真实**字节数(17.0 MB),不是假百分比。 + // 下载中写的是**真实**字节数(17 772 300 B → 16.9 MiB),不是假百分比。 let (value, _) = general::font_status(&zh, &CjkInstall::Downloading { bytes: 17_772_300 }); assert!( value.contains("16.9 MB"), diff --git a/crates/core/src/download/font.rs b/crates/core/src/download/font.rs index e38f00d..d65148c 100644 --- a/crates/core/src/download/font.rs +++ b/crates/core/src/download/font.rs @@ -56,7 +56,7 @@ use super::verify; /// 写进数据根**。 pub const MAX_FONT_BYTES: u64 = 32 * 1024 * 1024; -/// **单次候选尝试**的整体时限(不是连接超时,也不是空闲超时)。 +/// **非最后一个候选**的单次尝试时限(不是连接超时,也不是空闲超时)。 /// /// 为什么必须有:引擎的 `read_timeout` 是 30 s **空闲**超时,一条每秒只挤几 KB、但一直在动的 /// 连接永远不会被判失败。复审实测过这条路径(同一台机器、同一份文件): @@ -66,15 +66,31 @@ 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) | /// -/// 60~180 s 之间取 90 s:健康线路(实测 1.1~16.6 s)绰绰有余,而 75 KB/s 的爬行会在 90 s -/// 前被打断,转去下一个候选域。**超时归入网络档**(用户视角是「太慢」,不是「内容不对」)。 -pub const ATTEMPT_DEADLINE: Duration = Duration::from_secs(90); - -/// 整条取回(所有候选加起来)的总时限。 +/// 120 s 是「差不多能过、但明显不对就换源」的位置:健康线路(实测 1.1~16.6 s)根本用不到它; +/// 而 300 KB/s 以上的线路在 120 s 内能下完 17.7 MB。 /// -/// 候选是一个个试的,每个都有自己的 [`ATTEMPT_DEADLINE`];总时限是**上限的上限**,防止将来 -/// 候选列表变长时「N × 90 s」把设置页那一行卡成几分钟。当前两个独立域 ⇒ 实际最坏 3 分钟。 -pub const TOTAL_DEADLINE: Duration = Duration::from_secs(180); +/// ## 为什么**最后一个**候选不吃这个时限(复审 r2 P2-3) +/// +/// 每个候选都从 0 字节重下(不做续传),所以「单次上限」直接就是一条**吞吐门槛**: +/// `17 772 300 B / 上限`。第一版把两个候选都卡 90 s ⇒ 门槛 ≈197 KB/s,而 r1 实测那条 IPv6 是 +/// 67~77 KB/s——**改动前它能用 4 分钟下完,加了时限反而必失败、且重试无意义**。这正是我们想 +/// 避免的「把慢但当当能成的情况变成永久失败」。 +/// +/// 现在的规则(见 [`fetch_blocking_with_limits`]):前 n-1 个候选各拿 +/// `min(ATTEMPT_DEADLINE, 剩余)`,**最后一个候选拿整个剩余总预算**。所以只要这条线在 +/// [`TOTAL_DEADLINE`] 内下得完,它就还有一次老老实实下完的机会;而前面几个候选仍然会被快速换掉。 +/// **超时归入网络档**(用户视角是「太慢」,不是「内容不对」)。 +pub const ATTEMPT_DEADLINE: Duration = Duration::from_secs(120); + +/// 整条取回(所有候选加起来)的总时限,也是**最后一个候选**能拿到的预算上限。 +/// +/// 取值依据(复审 r2 P2-3 的算术):17 772 300 B / 360 s ≈ **49 KB/s** —— 比 r1 实测最慢的 +/// 那条 IPv6 路径(67~77 KB/s)还低一档,于是「慢但当当能下完」的线路仍然有一次成功机会; +/// 而任何健康线路(1.1~16.6 s)在第一或第二个候选上就结束了。 +/// +/// 代价:最坏情形下用户要在设置页看着「正在获取中文字体…(16.9 MB)」等 6 分钟。这是刻意的 +/// 取舍——**慢而最终成功**远好过**快而必然失败**(后者要用户自己再来一次,结果一样)。 +pub const TOTAL_DEADLINE: Duration = Duration::from_secs(360); /// 字体取回的失败分类(三层,见模块文档「失败分类为什么是三类」)。 #[derive(Debug, Clone, PartialEq, Eq)] @@ -194,8 +210,15 @@ pub fn fetch_blocking_with_limits( ))); break; } - // 每次尝试给 min(单次上限, 剩余总预算):两个时限都不许越。 - let budget = attempt_deadline.min(remaining); + // 前 n-1 个候选:`min(单次上限, 剩余)`;**最后一个候选**拿整个剩余总预算。 + // 为什么对最后一个放宽:每个候选都从 0 字节重下,「单次上限」就是一条吞吐门槛, + // 卡死最后一个等于把「慢但能下完」判成永久失败(复审 r2 P2-3 的算术,见 ATTEMPT_DEADLINE)。 + let is_last = index + 1 == spec.urls.len(); + let budget = if is_last { + remaining + } else { + attempt_deadline.min(remaining) + }; let request = DownloadRequest::new(ResourceKind::Other, url.clone(), &final_path) .with_sha256(&spec.sha256) .with_size(spec.bytes); diff --git a/crates/core/src/download/font/tests.rs b/crates/core/src/download/font/tests.rs index 479ba5c..adaff4a 100644 --- a/crates/core/src/download/font/tests.rs +++ b/crates/core/src/download/font/tests.rs @@ -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 { - 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, expected_requests: usize) -> (String, Arc) { - 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, 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(); } diff --git a/crates/core/src/download/font/tests/support.rs b/crates/core/src/download/font/tests/support.rs new file mode 100644 index 0000000..89f4eed --- /dev/null +++ b/crates/core/src/download/font/tests/support.rs @@ -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 { + 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, expected_requests: usize) -> (String, Arc) { + 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, + /// 写出去了几个正文字节。 + pub dripped: Arc, +} + +/// 由 URL 候选 + 期望字节算 pin(哈希取这批字节的 sha256)。 +pub(super) fn spec_for(urls: Vec, 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, + }, + ) +} diff --git a/crates/i18n/src/font.rs b/crates/i18n/src/font.rs index a89aaa1..324a510 100644 --- a/crates/i18n/src/font.rs +++ b/crates/i18n/src/font.rs @@ -97,7 +97,7 @@ pub struct CjkFont { pub license: &'static str, /// 这套字体可用的字重。 pub weights: &'static [u16], - /// 下载源(尚未钉死)。 + /// 下载源(URL 候选 + sha256 + 字节数,见 [`Source::Pinned`])。 pub source: Source, }