diff --git a/crates/app-iced/src/fonts.rs b/crates/app-iced/src/fonts.rs index d89c794..e1b123e 100644 --- a/crates/app-iced/src/fonts.rs +++ b/crates/app-iced/src/fonts.rs @@ -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| 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, } diff --git a/crates/app-iced/src/fonts/install.rs b/crates/app-iced/src/fonts/install.rs index 71f8540..afe9f87 100644 --- a/crates/app-iced/src/fonts/install.rs +++ b/crates/app-iced/src/fonts/install.rs @@ -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); diff --git a/crates/app-iced/src/fonts/install/tests.rs b/crates/app-iced/src/fonts/install/tests.rs index a8b9728..8d1267f 100644 --- a/crates/app-iced/src/fonts/install/tests.rs +++ b/crates/app-iced/src/fonts/install/tests.rs @@ -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:?}:下载中不许改状态、不许起第二条线程" ); diff --git a/crates/app-iced/src/fonts/install/worker.rs b/crates/app-iced/src/fonts/install/worker.rs index 31028fa..299008f 100644 --- a/crates/app-iced/src/fonts/install/worker.rs +++ b/crates/app-iced/src/fonts/install/worker.rs @@ -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`)。 diff --git a/crates/app-iced/src/fonts/tests.rs b/crates/app-iced/src/fonts/tests.rs index 5616c58..d516123 100644 --- a/crates/app-iced/src/fonts/tests.rs +++ b/crates/app-iced/src/fonts/tests.rs @@ -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 = ["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:?})" + ); +} diff --git a/crates/core/src/download/font.rs b/crates/core/src/download/font.rs index d65148c..1d3f731 100644 --- a/crates/core/src/download/font.rs +++ b/crates/core/src/download/font.rs @@ -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 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(); } diff --git a/crates/i18n/src/catalog/font_install.rs b/crates/i18n/src/catalog/font_install.rs index 65cea10..e4056e1 100644 --- a/crates/i18n/src/catalog/font_install.rs +++ b/crates/i18n/src/catalog/font_install.rs @@ -13,7 +13,7 @@ //! //! | 状态 | 文案说什么 | //! |---|---| -//! | 下载中 | 「正在获取中文字体…(≈17 MB)」——**没有**假进度条,有真实量(字节数)才写 | +//! | 下载中 | 「正在获取中文字体…(16.9 MB)」——**没有**假进度条,有真实量(字节数)才写 | //! | 完成 | 「中文字体已就绪:⟨族名⟩」(复用 `FontCjkReady`) | //! | 失败 | 「获取失败(⟨三类原因之一⟩)。界面照常可用,用的是系统字体;再切一次语言可以重试」 | //! | 无需下载 | 系统已有字体 / 当前语言不需要,两条分开说(对用户是两件事) | diff --git a/crates/i18n/src/font.rs b/crates/i18n/src/font.rs index 324a510..634238a 100644 --- a/crates/i18n/src/font.rs +++ b/crates/i18n/src/font.rs @@ -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",