diff --git a/crates/app-iced/src/app/boot.rs b/crates/app-iced/src/app/boot.rs index 8bb9c5d..182e421 100644 --- a/crates/app-iced/src/app/boot.rs +++ b/crates/app-iced/src/app/boot.rs @@ -1,56 +1,49 @@ -//! 启动:后端探测 → 字体求值 → `iced::application(..).run()`,外加 M98 的八类验收诊断。 +//! 启动编排:**探测 → 字体 → 打印诊断 → 起 iced**(四步,别把它撑回一坨)。 //! -//! ## M97:起步后端起不来时怎么办(两条路,策略不同,别合成一条) +//! ## 三条纪律(每条都对应一次返工) //! -//! `ICED_BACKEND` 永远是**单项**(见 [`crate::renderer`] 的判据表)。单项的代价是 iced 不再自己 -//! 回退,于是「wgpu 起不来」会变成 `iced::Error::GraphicsCreationFailed`——**这正是我们要的**: -//! 那里面装着 wgpu 的具名错误(哪个 backend、卡在 adapter 还是 device)。 +//! 1. **后端起不来时的两条路策略不同**(判据表第 8/9 行):交互启动换进程重开、取证启动 +//! (`--screenshot`)拒绝出图 —— 实现与测试都在 [`fallback`]。 +//! 2. **窗口设置只有一个产地**([`diagnostics::window_settings`]),`[window]` / `[caps]` 两行日志 +//! 从**同一个** `&window::Settings` 取字段。M98 r1 p2-1 的教训:把 `decorations=false` 写进 +//! 格式化串,等于让日志与真实行为可以各走各的(踩坑记录 U1)。 +//! 3. **没测过的就写 `skipped`**,不许拿 `0ms` 冒充实测([`diagnostics::report_font`] 的 `probe`)。 //! -//! - **交互启动**:换一个**进程**用 `tiny-skia` 重开([`OnFailure::Respawn`])——安全网,用户还能用。 -//! - **取证启动**(带 `--screenshot`,含 `--screen=gallery`):**拒绝出图**、退出码 1 -//! ([`OnFailure::Refuse`])——换了后端出的图会让报告撒谎。 -//! -//! 为什么必须换进程:Windows 实测本进程里再 `run()` 一次直接 panic -//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)。别再猜着改回去。 -//! -//! ## M98:诊断的落点 -//! -//! 八类日志由 [`crate::renderer::acceptance`] 排版;本文件只在**正确的时刻**把真值喂给它(探测 -//! 产物 / 窗口设置 / 实测 `Instant`);判据与证据的边界写在那边的模块文档里。 +//! 为什么重开必须是**换进程**:Windows 实测本进程里再 `run()` 一次直接 panic +//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)。 + use std::time::{Duration, Instant}; -use iced::{window, Task}; +use iced::Task; use unlucky_design::color::SchemeFlavor; use crate::cli::Options; use crate::fonts; use crate::probe; -use crate::renderer::acceptance; -use crate::renderer::{self, Decision, Kind}; +use crate::renderer::{self, acceptance, Decision}; use crate::theme::Families; use super::message::Message; use super::state::{App, STARTED}; -use super::{APP_TITLE, MIN_SIZE, WINDOW_SIZE}; +use super::{MIN_SIZE, WINDOW_SIZE}; + +mod diagnostics; +mod fallback; + +use fallback::{on_failure, respawn, OnFailure}; /// 启动 iced(阻塞到退出)。`--diff` 不走这里,见 `main.rs`。 pub(crate) fn run(options: Options) -> iced::Result { let _ = STARTED.set(Instant::now()); let boot_started = Instant::now(); - // 贵的诊断(适配器枚举 / `defaults` / `reg.exe`)只在验收模式下跑;便宜的行恒开。 - if acceptance::enabled() { - acceptance::announce(); - } - + // ① 探测(判据取在哪、日志在 `report_boot` 里统一打) let boot = boot_probe(&options); let decision = boot.decision; - acceptance::report_backend_plan(&decision, options.backend); - if acceptance::enabled() { - acceptance::report_startup(&boot.probe, options.scale_factor); - } + diagnostics::report_boot(&options, &boot.probe, &decision); + // ② 字体(`fonts` 自己打两行既有日志;新加的那行在 `report_font` 里) let font_started = Instant::now(); let resolved = fonts::resolve(); let font_ms = font_started.elapsed(); @@ -61,35 +54,24 @@ pub(crate) fn run(options: Options) -> iced::Result { "[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换字族)要处理" ); } - if acceptance::enabled() { - acceptance::report_font_chain(resolved.platform); - } let families = resolved.families(); + // ③ 打印(主题来源 + 字体回退链 + 启动耗时) // 截图模式:`--screenshot-theme` > `--theme` > **Light**(唯一取值口 `Options::capture_flavor`)。 // **不再有**「等系统主题到达」的闸门——CI 的 xvfb 没有主题会话,等它就是挂死(见 cli 文档)。 if options.screenshot.is_some() && options.shot_flavor().is_none() { eprintln!("[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)"); } let flavor = options.capture_flavor().unwrap_or(SchemeFlavor::Light); - - // `[theme]`:系统那条是异步的 ⇒ `follows_system=true` 时 `flavor=pending`,真值随后由 - // `app/update.rs` 的 `[theme] 系统主题 = …` 给出(两行合起来才是完整答案)。 - - let (source, follows) = acceptance::theme_source( - options.screenshot_theme.is_some(), - options.theme.is_some(), - options.screenshot.is_some(), + diagnostics::report_theme(&options, flavor); + diagnostics::report_font( + resolved.platform, + boot.probe_ms, + font_ms, + boot_started.elapsed(), ); - let explicit = (!follows).then_some(if flavor.is_dark() { "dark" } else { "light" }); - acceptance::report_theme(source, explicit, follows); - if acceptance::enabled() { - acceptance::report_perf_boot(boot.probe_ms, font_ms, boot_started.elapsed()); - } - - // 起步后端起不来时按判据表第 8/9 行分流(交互降级 vs 取证拒绝)。具名原因在分流之前 - // 就打到同一个 stderr 上了。 + // ④ 起 iced(失败按判据表第 8/9 行分流;具名原因在分流之前就打到同一个 stderr 上了) let launch_started = Instant::now(); let result = match launch(&options, families, flavor, decision.primary) { Err(iced::Error::GraphicsCreationFailed(error)) => { @@ -119,141 +101,29 @@ pub(crate) fn run(options: Options) -> iced::Result { }; let launch_elapsed = launch_started.elapsed(); - // `[backend] final`:**结果**(不是请求)。判据是单项 `ICED_BACKEND`(判据表第 1~7 行): - // 候选只有一项 ⇒ 进程能起来就必定是它,没有静默回退的空间。 - match &result { - Ok(()) => acceptance::report_backend_started(decision.primary, launch_elapsed), - Err(error) => { - let named = renderer::named_graphics_error(error).unwrap_or_else(|| error.to_string()); - acceptance::report_backend_failed( - decision.primary, - decision.fallback, - acceptance::failure_via(decision.fallback.is_some(), options.screenshot.is_some()), - &named, - ); - } - } - if acceptance::enabled() { - acceptance::report_perf_session( - if options.screenshot.is_some() { - "screenshot" - } else { - "interactive" - }, - launch_elapsed, - result.is_ok(), - ); - } + diagnostics::report_outcome( + &decision, + result.as_ref().err().map(|error| { + renderer::named_graphics_error(error).unwrap_or_else(|| error.to_string()) + }), + acceptance::failure_via(decision.fallback.is_some(), options.screenshot.is_some()), + if options.screenshot.is_some() { + "screenshot" + } else { + "interactive" + }, + launch_elapsed, + ); result } -/// 起步后端失败后的去向(判据表第 8 行 vs 第 9 行——**两条路策略不同,别合成一条**)。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum OnFailure { - /// 交互启动:安全网优先,换进程用这个后端重开。 - Respawn(Kind), - /// 取证启动:红着退出,**不出图**。 - Refuse, -} - -/// 纯判据:起步后端失败时走哪条路。 -/// -/// `evidence` 非空 = 本次是取证启动(带 `--screenshot`);返回 `None` = 没有兜底可用 -/// (`--renderer=cpu` / 未编 wgpu),原样把错误交回去。抽成纯函数只为一个理由:这条分支以前 -/// **没有任何测试**,`respawn` / `Refuse` 两条路可以被静默改反(M97 r1 p2-1 就是这么测出来的)。 -fn on_failure(decision: Decision, evidence: Option<&std::path::PathBuf>) -> Option { - let fallback = decision.fallback?; - Some(match evidence { - Some(_) => OnFailure::Refuse, - None => OnFailure::Respawn(fallback), - }) -} - -/// 降级重开要传给子进程的环境变量(**两条缺一不可**)。 -/// -/// ① `ICED_BACKEND`:让子进程直接用 `fallback` 跑。 -/// ② [`renderer::FALLBACK_ENV`]:**防无限重开的闸门**——子进程见它就必须跳过探测 -/// (`boot_probe` 的第一个分支),否则会算出同一个结论、再失败、再 respawn。 -/// -/// 抽成纯函数是为了让「漏传其中之一」变成**测试能抓到**的错误:两条都是静默失效型 -/// (漏 ① 重开又跑一遍 wgpu、漏 ② 无限重开),线上极难看出来。 -fn fallback_env(fallback: Kind) -> [(&'static str, &'static str); 2] { - [ - ("ICED_BACKEND", fallback.env_value()), - (renderer::FALLBACK_ENV, fallback.env_value()), - ] -} - -/// 安全网:起一个**新进程**用 `fallback` 后端重开,然后带着它的退出码退出。 -/// -/// 只在**交互启动**时走到这里(取证启动宁可红着退出也不换后端出图);子进程看到 -/// [`renderer::FALLBACK_ENV`] 必须跳过探测,否则无限递归重开(闸门在 `boot_probe` 入口)。 -fn respawn(failed: Kind, fallback: Kind, error: &str) -> ! { - let exe = std::env::current_exe().unwrap_or_else(|err| { - eprintln!("[gpu] 降级重开失败:拿不到自身路径({err})"); - std::process::exit(exit_code(None)); - }); - eprintln!( - "[gpu] 降级 → {}({} 已失败,原因见上两行;换一个进程重开:winit 不允许同进程建两次事件循环)。\ - 下一次的日志从 `[probe] 本次是降级重开` 开始。", - fallback.name(), - failed.name() - ); - // `[backend] final` 一行把「哪个后端失败了、去了哪、为什么」收在一起(可 grep 的单一结论行)。 - acceptance::report_backend_failed(failed, Some(fallback), "respawn", error); - - let status = fallback_command(&exe, std::env::args_os().skip(1), fallback).status(); - - // 退出码**透传**子进程的:上层脚本/CI 靠它判断成败。写错的失效方式是「安静地给出一个错 - // 结论」——不崩、不静默降级、不无限重开,只是让 CI 把失败当成功。 - let code = match status { - Ok(status) => exit_code(status.code()), - Err(err) => { - eprintln!("[gpu] 降级重开失败:{err}"); - exit_code(None) - } - }; - std::process::exit(code) -} - -/// 子进程退出码 → 本进程退出码。 -/// -/// `None` = 子进程被信号杀死(只有 Unix):走兜底码而**不是** panic、也不是 0——上层判据是 -/// 「非零 = 失败」,「拿不到退出码」和「退出码是某个非零值」对脚本是一回事。 -fn exit_code(code: Option) -> i32 { - /// 重开失败 / 拿不到退出码时的兜底码(与 `main` 的「参数或运行错误」一致)。 - const RESPAWN_FAILED: i32 = 2; - code.unwrap_or(RESPAWN_FAILED) -} - -/// 构造降级重开的子进程命令(**可测**:program / argv / env 都能在测试里断言)。 -/// -/// `program` / `args` 是参数而不是在函数里读 `std::env`,唯一目的是让测试能喂假命令 -/// (`cmd /c exit 7` / `sh -c "exit 7"`)来验退出码透传——真用 `current_exe()` 就没法测。 -/// -/// 三路 stdio **显式**继承:子进程日志必须和父进程同一路输出,否则验收只抓到父进程那两行具名 -/// 错误。`Command` 没有 stdio 的 getter(stable 没有),所以这条只能靠真跑一次证明。 -fn fallback_command( - program: &std::path::Path, - args: impl IntoIterator, - fallback: Kind, -) -> std::process::Command { - let mut command = std::process::Command::new(program); - command.args(args).envs(fallback_env(fallback)); - command - .stdin(std::process::Stdio::inherit()) - .stdout(std::process::Stdio::inherit()) - .stderr(std::process::Stdio::inherit()); - command -} - /// 按一次后端启动 iced。`backend` 决定这次写进 `ICED_BACKEND` 的唯一一项。 fn launch( options: &Options, families: Families, flavor: SchemeFlavor, - backend: Kind, + backend: renderer::Kind, ) -> iced::Result { std::env::set_var("ICED_BACKEND", backend.env_value()); @@ -265,34 +135,9 @@ fn launch( (WINDOW_SIZE.width, WINDOW_SIZE.height), (MIN_SIZE.width, MIN_SIZE.height), ); - - let settings = window::Settings { - size: iced::Size::new(size.0, size.1), - min_size: Some(MIN_SIZE), - decorations: false, - resizable: true, - // M93 / D36:整窗 α 需要 `WS_EX_LAYERED`,但这里给不了——`window::Settings` 没有扩展样式 - // 字段,写它只有 `Get/SetWindowLongPtrW`(`unsafe`,全仓只允许出现在 - // `platform/win32/transparency.rs`)。所以 α 下发在拿到句柄之后(`Message::WindowHandle`)。 - transparent: false, - ..window::Settings::default() - }; - - // 值**直接取自**上面那个 `settings`(另抄一份就会漂;验收靠「日志说的 = 给窗口的」才有意义)。 - acceptance::report_window( - (settings.size.width, settings.size.height), - Some((MIN_SIZE.width, MIN_SIZE.height)), - options.scale_factor, - size_source, - Some(APP_TITLE), - ); - let exe = std::env::current_exe().ok(); - acceptance::report_caps( - exe.as_deref(), - settings.transparent, - settings.resizable, - settings.decorations, - ); + let settings = diagnostics::window_settings(size); + // 两行日志与开窗用的是**同一个** settings(见模块文档纪律 2) + diagnostics::report_window(&settings, options.scale_factor, size_source); let application = iced::application( move || { @@ -316,14 +161,14 @@ fn launch( } } -/// [`boot_probe`] 的产物:判据 + 探测到的适配器 + 探测耗时。 +/// [`boot_probe`] 的产物:判据 + 探测到的适配器 + **探测耗时(没测就是 `None`)**。 /// /// 为什么把 `Probe` 带出来:`[env]` 的 `gpu=` / `hardware=` 必须是**这一次真枚举到的**结果 /// (再枚举一遍多花 0.65~1.2 s,还可能给出不同状态)。判据与诊断看同一份数据。 struct Boot { decision: Decision, probe: probe::Probe, - probe_ms: Duration, + probe_ms: Option, } /// 渲染后端探测 + 写 `ICED_BACKEND`,返回判据产物(供 `run` 决定要不要换进程重开)。 @@ -340,44 +185,47 @@ fn boot_probe(options: &Options) -> Boot { kind.env_value() ); eprintln!("[probe] {}", decision.describe()); - // 验收要 `[env]` 里的真实适配器:这一轮也枚举(否则日志会写 `adapters=0`,与事实相反 - // ——GPU 刚刚才被枚举到、只是起不来)。只有验收开关打开时才付这 0.65~1.2 s。 - let probe = if acceptance::enabled() { - probe::Probe::run() + // 验收要 `[env]` 里的真实适配器:这一轮**也枚举一次,并如实计时**(没计时就 `None` + // ⇒ 日志写 `probe=skipped`,不写 0ms)。 + let (probe, probe_ms) = if acceptance::enabled() { + let started = Instant::now(); + let probe = probe::Probe::run(); + (probe, Some(started.elapsed())) } else { - probe::Probe::default() + (probe::Probe::default(), None) }; return Boot { decision, probe, - probe_ms: Duration::ZERO, + probe_ms, }; } - let probe_started = Instant::now(); // 只有 `auto` 需要探测(`cpu`/`gpu` 的决策与硬件无关,枚举 13 个适配器本机要 0.65~1.2 s)。 // 验收模式例外:`[env]` 那行要 GPU 名,不管请求是哪个后端。 let needs_probe = options.backend == crate::Backend::Auto || acceptance::enabled(); + let probe_started = Instant::now(); let probe = if needs_probe { probe::Probe::run() } else { probe::Probe::default() }; - let probe_ms = probe_started.elapsed(); + let probe_ms = needs_probe.then(|| probe_started.elapsed()); // `ULL_PREFLIGHT=1` 时先自己验一遍 wgpu(判据表第 7 行);默认不跑,见 preflight 模块文档。 let preflight = renderer::preflight::run_if_requested(); let decision = probe::apply_with_preflight(options.backend, &probe, preflight.as_ref()); if cfg!(feature = "gpu") { - let ms = probe_ms.as_secs_f64() * 1000.0; - if needs_probe { - eprintln!("[probe] 探测耗时 {ms:.0} ms(wgpu 实例 + 全后端适配器枚举)"); - } else { - eprintln!( - "[probe] 探测耗时 {ms:.0} ms(--renderer={} 与硬件无关,跳过枚举)", + match probe_ms { + Some(elapsed) => eprintln!( + "[probe] 探测耗时 {:.0} ms(wgpu 实例 + 全后端适配器枚举)", + elapsed.as_secs_f64() * 1000.0 + ), + None => eprintln!( + "[probe] 探测耗时 跳过(--renderer={} 与硬件无关,未枚举)", options.backend.name() - ); + ), } } Boot { diff --git a/crates/app-iced/src/app/boot/diagnostics.rs b/crates/app-iced/src/app/boot/diagnostics.rs new file mode 100644 index 0000000..9ac2c7f --- /dev/null +++ b/crates/app-iced/src/app/boot/diagnostics.rs @@ -0,0 +1,110 @@ +//! 启动阶段的**验收诊断编排**:把 `app/boot.rs` 的 `run()` 收成「探测 → 字体 → 打印 → 起 iced」。 +//! +//! ## 为什么单独一个文件(M98 r1 p2-1 的根因) +//! +//! 第一版把 `[window] requested … resizable=true decorations=false transparent=false` 直接写在 +//! 格式化串里,而 `boot.rs` 给窗口的 `settings` 是另一处常量 —— 两者**没有任何绑定**。定向变异 +//! 把 `settings.decorations` 改成 `true`,371 个测试全绿,而验收包里 `[caps] decorations=true` 与 +//! `[window] decorations=false` 会互相矛盾(踩坑记录 U1:常量冒充观测结果)。 +//! +//! 现在:窗口设置由 [`window_settings`] **一处**产生,日志由 [`report_window`] 从**同一个 +//! `&window::Settings`** 取字段。钉死它的守门测试在 `boot/tests.rs`(`window_settings_*`)。 +//! +//! 另一件事:所有「跑没跑」都要能如实说。降级重开那一轮没计时就写 `probe=skipped`, +//! 不许拿 `0ms` 冒充一次实测(M98 r1 p3-1)。 + +use std::time::Duration; + +use iced::window; + +use unlucky_design::color::SchemeFlavor; +use unlucky_design::typography::Platform as FontPlatform; + +use crate::cli::Options; +use crate::probe::Probe; +use crate::renderer::{acceptance, Decision}; + +use super::MIN_SIZE; +use crate::app::APP_TITLE; + +/// 产品窗口设置(**唯一产地**:`launch()` 用它开窗,`[window]` / `[caps]` 从它取字段)。 +/// +/// `ULL_WINDOW_SIZE` 的覆盖在 `boot.rs` 解析成 `size` 后从这里进来 —— 日志与真窗口永远同源。 +pub(super) fn window_settings(size: (f32, f32)) -> window::Settings { + window::Settings { + size: iced::Size::new(size.0, size.1), + min_size: Some(MIN_SIZE), + // 自绘三键(M93):窗口**不许**带系统装饰,否则 macOS 上会出现原生标题栏 + 自绘三键两套。 + decorations: false, + resizable: true, + // M93 / D36:整窗 α 需要 `WS_EX_LAYERED`,但这里给不了——`window::Settings` 没有扩展样式 + // 字段,写它只有 `Get/SetWindowLongPtrW`(`unsafe`,全仓只允许出现在 + // `platform/win32/transparency.rs`)。所以 α 下发在拿到句柄之后(`Message::WindowHandle`)。 + transparent: false, + ..window::Settings::default() + } +} + +/// `[window] requested` + `[caps]` 两行,**都从同一个 `settings` 取**(不给手抄字面量的机会)。 +pub(super) fn report_window(settings: &window::Settings, scale: Option, size_source: &str) { + let facts = acceptance::WindowFacts::from_settings(settings); + acceptance::report_window(&facts, scale, size_source, Some(APP_TITLE)); + acceptance::report_caps( + std::env::current_exe().ok().as_deref(), + facts.transparent, + facts.resizable, + facts.decorations, + ); +} + +/// 第一段:`[acceptance] on` + `[backend] plan` +(验收模式)`[env]` / `[i18n]`。 +pub(super) fn report_boot(options: &Options, probe: &Probe, decision: &Decision) { + if acceptance::enabled() { + acceptance::announce(); + } + acceptance::report_backend_plan(decision, options.backend); + if acceptance::enabled() { + acceptance::report_startup(probe, options.scale_factor); + } +} + +/// 第二段:`[font] chain` + `[perf] boot`(都要 `ULL_ACCEPTANCE=1`)。 +/// +/// `probe = None` = 这一轮**没有探测**(既不是 `auto` 也没开验收)⇒ 打 `probe=skipped`。 +pub(super) fn report_font( + platform: FontPlatform, + probe: Option, + font: Duration, + to_run: Duration, +) { + if acceptance::enabled() { + acceptance::report_font_chain(platform); + acceptance::report_perf_boot(probe, font, to_run); + } +} + +/// 第三段:`[theme]`(恒开:它解释的是「这次主题凭什么是这个」)。 +pub(super) fn report_theme(options: &Options, flavor: SchemeFlavor) { + let (source, follows) = acceptance::theme_source( + options.screenshot_theme.is_some(), + options.theme.is_some(), + options.screenshot.is_some(), + ); + let explicit = (!follows).then_some(if flavor.is_dark() { "dark" } else { "light" }); + acceptance::report_theme(source, explicit, follows); +} + +/// 第四段:`[backend] final`(恒开)+ `[perf] session`(验收模式)。 +/// +/// `error` = 具名失败原因(`None` = 起来了);`via` 见 `renderer::acceptance::failure_via`。 +/// `refuse` / `none` 两条路**什么都没跑起来** ⇒ `final=none`;只有 `respawn` 才有「换到哪个 +/// 后端」可言(那一格是兜底名,子进程自己还会再打一遍自己的 plan/final)。 +pub(super) fn report_outcome( + decision: &Decision, + error: Option, + via: &str, + mode: &str, + elapsed: Duration, +) { + acceptance::report_outcome(decision, error.as_deref(), via, mode, elapsed); +} diff --git a/crates/app-iced/src/app/boot/fallback.rs b/crates/app-iced/src/app/boot/fallback.rs new file mode 100644 index 0000000..ef20a28 --- /dev/null +++ b/crates/app-iced/src/app/boot/fallback.rs @@ -0,0 +1,117 @@ +//! 起步后端失败后的**兜底/重开**一簇:判据、进程环境交接、`respawn`、退出码透传。 +//! +//! 从 `app/boot.rs` 拆出来的理由:这一簇**与启动编排零耦合**,但已有 5 条测试,而且它是 +//! M97 判据表第 8/9 行的落点。`boot.rs` 那边只负责「什么时候调它」。 +//! +//! ## 为什么必须换进程(别改回进程内重试) +//! +//! Windows 实测本进程里再 `run()` 一次直接 panic +//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`),winit 一个进程 +//! 只能建一次事件循环。 + +use crate::renderer::Decision; +use crate::renderer::{self, acceptance, Kind}; + +/// 起步后端失败后的去向(判据表第 8 行 vs 第 9 行——**两条路策略不同,别合成一条**)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum OnFailure { + /// 交互启动:安全网优先,换进程用这个后端重开。 + Respawn(Kind), + /// 取证启动:红着退出,**不出图**。 + Refuse, +} + +/// 纯判据:起步后端失败时走哪条路。 +/// +/// `evidence` 非空 = 本次是取证启动(带 `--screenshot`);返回 `None` = 没有兜底可用 +/// (`--renderer=cpu` / 未编 wgpu),原样把错误交回去。抽成纯函数只为一个理由:这条分支以前 +/// **没有任何测试**,`respawn` / `Refuse` 两条路可以被静默改反(M97 r1 p2-1 就是这么测出来的)。 +pub(super) fn on_failure( + decision: Decision, + evidence: Option<&std::path::PathBuf>, +) -> Option { + let fallback = decision.fallback?; + Some(match evidence { + Some(_) => OnFailure::Refuse, + None => OnFailure::Respawn(fallback), + }) +} + +/// 降级重开要传给子进程的环境变量(**两条缺一不可**)。 +/// +/// ① `ICED_BACKEND`:让子进程直接用 `fallback` 跑。 +/// ② [`renderer::FALLBACK_ENV`]:**防无限重开的闸门**——子进程见它就必须跳过探测 +/// (`boot_probe` 的第一个分支),否则会算出同一个结论、再失败、再 respawn。 +/// +/// 抽成纯函数是为了让「漏传其中之一」变成**测试能抓到**的错误:两条都是静默失效型 +/// (漏 ① 重开又跑一遍 wgpu、漏 ② 无限重开),线上极难看出来。 +pub(super) fn fallback_env(fallback: Kind) -> [(&'static str, &'static str); 2] { + [ + ("ICED_BACKEND", fallback.env_value()), + (renderer::FALLBACK_ENV, fallback.env_value()), + ] +} + +/// 安全网:起一个**新进程**用 `fallback` 后端重开,然后带着它的退出码退出。 +/// +/// 只在**交互启动**时走到这里(取证启动宁可红着退出也不换后端出图);子进程看到 +/// [`renderer::FALLBACK_ENV`] 必须跳过探测,否则无限递归重开(闸门在 `boot_probe` 入口)。 +pub(super) fn respawn(failed: Kind, fallback: Kind, error: &str) -> ! { + let exe = std::env::current_exe().unwrap_or_else(|err| { + eprintln!("[gpu] 降级重开失败:拿不到自身路径({err})"); + std::process::exit(exit_code(None)); + }); + eprintln!( + "[gpu] 降级 → {}({} 已失败,原因见上两行;换一个进程重开:winit 不允许同进程建两次事件循环)。\ + 下一次的日志从 `[probe] 本次是降级重开` 开始。", + fallback.name(), + failed.name() + ); + // `[backend] final` 一行把「哪个后端失败了、去了哪、为什么」收在一起(可 grep 的单一结论行)。 + // 这里 `final=` 装的是**兜底**(进程即将带着它重开);`via=respawn` 说明它是换进程去的。 + acceptance::report_backend_failed(failed, Some(fallback), Some(fallback), "respawn", error); + + let status = fallback_command(&exe, std::env::args_os().skip(1), fallback).status(); + + // 退出码**透传**子进程的:上层脚本/CI 靠它判断成败。写错的失效方式是「安静地给出一个错 + // 结论」——不崩、不静默降级、不无限重开,只是让 CI 把失败当成功。 + let code = match status { + Ok(status) => exit_code(status.code()), + Err(err) => { + eprintln!("[gpu] 降级重开失败:{err}"); + exit_code(None) + } + }; + std::process::exit(code) +} + +/// 子进程退出码 → 本进程退出码。 +/// +/// `None` = 子进程被信号杀死(只有 Unix):走兜底码而**不是** panic、也不是 0——上层判据是 +/// 「非零 = 失败」,「拿不到退出码」和「退出码是某个非零值」对脚本是一回事。 +pub(super) fn exit_code(code: Option) -> i32 { + /// 重开失败 / 拿不到退出码时的兜底码(与 `main` 的「参数或运行错误」一致)。 + const RESPAWN_FAILED: i32 = 2; + code.unwrap_or(RESPAWN_FAILED) +} + +/// 构造降级重开的子进程命令(**可测**:program / argv / env 都能在测试里断言)。 +/// +/// `program` / `args` 是参数而不是在函数里读 `std::env`,唯一目的是让测试能喂假命令 +/// (`cmd /c exit 7` / `sh -c "exit 7"`)来验退出码透传——真用 `current_exe()` 就没法测。 +/// +/// 三路 stdio **显式**继承:子进程日志必须和父进程同一路输出,否则验收只抓到父进程那两行具名 +/// 错误。`Command` 没有 stdio 的 getter(stable 没有),所以这条只能靠真跑一次证明。 +pub(super) fn fallback_command( + program: &std::path::Path, + args: impl IntoIterator, + fallback: Kind, +) -> std::process::Command { + let mut command = std::process::Command::new(program); + command.args(args).envs(fallback_env(fallback)); + command + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()); + command +} diff --git a/crates/app-iced/src/app/boot/tests.rs b/crates/app-iced/src/app/boot/tests.rs index 3765fd0..b247030 100644 --- a/crates/app-iced/src/app/boot/tests.rs +++ b/crates/app-iced/src/app/boot/tests.rs @@ -18,9 +18,12 @@ //! 把断言一起改了。这类假绿最阴:变异验证本来是我们判断「测试够不够」的终极手段, //! 手段本身假绿时,它还会附赠「已验证」的强信心。 +use super::fallback::*; use super::*; +use crate::renderer::acceptance::WindowFacts; use crate::renderer::decision; +use crate::renderer::Kind; use std::ffi::{OsStr, OsString}; use std::path::Path; @@ -185,3 +188,84 @@ fn no_fallback_means_no_respawn() { let not_compiled = decision::decide(crate::Backend::Auto, &probe("IntegratedGpu"), false); assert_eq!(on_failure(not_compiled, None), None); } + +/// **窗口设置就是验收日志里那三个字段的来源**(M98 r1 p2-1 的守门)。 +/// +/// 抓得住:把 `decorations` 改回 `true`(自绘三键退回系统装饰)、把 `resizable` / `transparent` +/// 打开 —— 这三种改动都会让 `[window]` 与 `[caps]` 两行与代码自相矛盾,而在此之前**371 个测试 +/// 全绿**(复审 M6 变异实证)。现在它们必须红在这里。 +/// 抓不住:winit 是否真按这些设置建窗(那要真机看窗口,见判据表「谁能验证」列)。 +#[test] +fn window_settings_match_what_the_acceptance_logs_claim() { + let settings = diagnostics::window_settings((1180.0, 760.0)); + + assert!( + !settings.decorations, + "M93 自绘三键:窗口不能带系统装饰(否则 macOS 上会出现两套标题栏)" + ); + assert!( + settings.resizable, + "验收要验「能否缩放」,窗口必须声明可缩放" + ); + assert!( + !settings.transparent, + "整窗透明只在 win32 拿到句柄后下发(D36),窗口创建侧不许打开它" + ); + assert_eq!( + settings.min_size, + Some(MIN_SIZE), + "最小尺寸档(880×560)是矩阵的一格,不能被改动" + ); + + // 日志确实从上面这个 settings 取值(而不是另抄一份字面量) + let facts = WindowFacts::from_settings(&settings); + let line = crate::renderer::acceptance::window_text( + &facts, + None, + "default", + Some(super::super::APP_TITLE), + ); + assert!(line.contains("resizable=true"), "{line}"); + assert!(line.contains("decorations=false"), "{line}"); + assert!(line.contains("transparent=false"), "{line}"); + assert!(line.contains("size=1180x760"), "{line}"); + assert!(line.contains("min=880x560"), "{line}"); + + // 抽字段这一步必须**忠实**:拿一份处处相反的 settings 进来,facts 必须跟着反 + // (抓的是「from_settings 里又写死一份常量」这条回退路 —— M98 r1 M7 变异实证它当年漏抓) + let odd = iced::window::Settings { + size: iced::Size::new(640.0, 480.0), + min_size: None, + decorations: true, + resizable: false, + transparent: true, + ..iced::window::Settings::default() + }; + assert_eq!( + WindowFacts::from_settings(&odd), + WindowFacts { + size: (640.0, 480.0), + min: None, + resizable: false, + decorations: true, + transparent: true, + }, + "from_settings 必须逐字段搬运,不能在内部再写死一份常量" + ); + + // 反向:把事实翻个身,日志必须跟着变(证明没有第二份真值) + let flipped = WindowFacts { + decorations: true, + ..facts + }; + let line = crate::renderer::acceptance::window_text( + &flipped, + None, + "default", + Some(super::super::APP_TITLE), + ); + assert!( + line.contains("decorations=true"), + "日志必须跟着 settings 走:{line}" + ); +} diff --git a/crates/app-iced/src/pages/harness.rs b/crates/app-iced/src/pages/harness.rs index 25cc3eb..258835e 100644 --- a/crates/app-iced/src/pages/harness.rs +++ b/crates/app-iced/src/pages/harness.rs @@ -203,18 +203,25 @@ pub fn run(request: Request) -> Result<(), String> { decision.reason ); renderer::acceptance::report_backend_plan(&decision, request.backend); - renderer::acceptance::report_window( - (request.width, request.height), - None, - request.scale_factor, - "pages_demo", - None, - ); let resolved = fonts::resolve(); eprintln!("{}", resolved.describe()); let families = resolved.families(); + // 这一份就是喂给 iced 的真值:`[window]` 那行从它取字段(M98 r1 p2-1 的同一个教训) + let settings = window::Settings { + size: iced::Size::new(request.width, request.height), + decorations: false, + resizable: true, + ..window::Settings::default() + }; + renderer::acceptance::report_window( + &renderer::acceptance::WindowFacts::from_settings(&settings), + request.scale_factor, + "pages_demo", + None, + ); + let application = |backend: renderer::Kind| { let boot = request.clone(); iced::application( @@ -242,12 +249,7 @@ pub fn run(request: Request) -> Result<(), String> { family: iced::font::Family::Name(families.text), ..iced::Font::DEFAULT }) - .window(window::Settings { - size: iced::Size::new(request.width, request.height), - decorations: false, - resizable: true, - ..window::Settings::default() - }) + .window(settings) }; eprintln!( @@ -280,10 +282,11 @@ pub fn run(request: Request) -> Result<(), String> { None => format!("iced 启动失败:{error}"), }); + // 取证通道恒为 evidence 启动 ⇒ 有兜底时 via=refuse、没兜底时 none(不再写死,M98 r1 p3-4) renderer::acceptance::report_outcome( &decision, outcome.as_ref().err().map(String::as_str), - "refuse", + renderer::acceptance::failure_via(decision.fallback.is_some(), true), "screenshot", session, ); diff --git a/crates/app-iced/src/renderer/acceptance/locale.rs b/crates/app-iced/src/renderer/acceptance/locale.rs index ae3e3f0..a0284f1 100644 --- a/crates/app-iced/src/renderer/acceptance/locale.rs +++ b/crates/app-iced/src/renderer/acceptance/locale.rs @@ -36,23 +36,35 @@ pub fn env_line(probe: &Probe, language: &str, scale: Option, tz: Option<&s ) } -/// 枚举结果里**最该被当成 GPU** 的那个适配器的摘要(硬件优先,其次任何适配器)。 +/// 枚举结果里**最该被当成 GPU** 的那个适配器的摘要:`DiscreteGpu > IntegratedGpu > 其它`。 /// -/// 与 `Probe::describe` 的分工:那个给全量清单(日志一行),这个给「一句话结论」(报告表格用)。 -/// 判据同源:`device_type` 不是 `Cpu` / `Other` 才算硬件(WARP 排在第一是本机实测的形状)。 +/// 为什么不是「第一个非 Cpu/Other」(M98 r1 p3-2):本机枚举顺序是核显在前,于是 `[env] gpu=` +/// 报的是 `AMD Radeon(TM) Graphics [IntegratedGpu]`,而 iced 实际选的是 +/// `AMD Radeon RX 6600 XT [DiscreteGpu]`。一行「机器有什么显卡」的日志报错型号,比不报更坏。 +/// 判据仍然只管**型号名称**:真正的「iced 选了谁」只有 `[gpu] …Selected: AdapterInfo` 那行有。 #[must_use] pub fn gpu_summary(probe: &Probe) -> String { probe .adapters .iter() - .find(|adapter| !matches!(adapter.device_type, "Cpu" | "Other")) - .or_else(|| probe.adapters.first()) + .max_by_key(|adapter| gpu_rank(adapter.device_type)) .map_or_else( || "<无适配器>".to_owned(), |adapter| format!("{} [{}]", adapter.name, adapter.device_type), ) } +/// 适配器的「该不该被当成这台机器的显卡」排序权重(越大越该)。 +fn gpu_rank(device_type: &str) -> u8 { + match device_type { + "DiscreteGpu" => 3, + "IntegratedGpu" => 2, + "VirtualGpu" => 1, + "Cpu" | "Other" => 0, + _ => 1, + } +} + /// 时区(`TZ` 环境变量)。`std` 拿不到系统时区,所以这里**只报环境变量**,没有就写未设置。 /// /// macOS 上的真值时区由脚本用 `date +%Z%z` 采(那份是观测值,这个只是进程视角)。 @@ -129,7 +141,9 @@ impl Chain { pub fn line(&self) -> String { format!( "[i18n] setting={} platform={}:{} env={}:{} -> {} ui_text=pinned-en", - show(self.setting.as_deref()), + // `<未接线>` 而不是 `<未设置>`:配置侧的读取点**还不存在**(M98 r1 p3-3)。 + // 写「未设置」会让读的人以为用户没配过;真实情况是我们没读。 + self.setting.as_deref().unwrap_or("<未接线>"), self.platform.0, show(self.platform.1.as_deref()), self.env.0, diff --git a/crates/app-iced/src/renderer/acceptance/mod.rs b/crates/app-iced/src/renderer/acceptance/mod.rs index f054094..523a2f5 100644 --- a/crates/app-iced/src/renderer/acceptance/mod.rs +++ b/crates/app-iced/src/renderer/acceptance/mod.rs @@ -33,20 +33,18 @@ use crate::{Backend, Probe}; pub mod locale; pub mod visual; +pub mod window; pub use locale::{env_line, tz, Chain}; pub use visual::{caps_line, font_chain_line, report_caps, report_font_chain}; +pub use window::{ + parse_window_size, report_window, resolve_window_size, window_size, window_text, WindowFacts, + WINDOW_SIZE_ENV, +}; /// 打开「贵的诊断」的开关(见模块文档 §开关)。 pub const ACCEPTANCE_ENV: &str = "ULL_ACCEPTANCE"; -/// 窗口尺寸覆盖(`ULL_WINDOW_SIZE=1520x900`)。 -/// -/// 为什么是环境变量而不是 `--width`:`cli.rs` 不在 M98 的 scope,而验收矩阵必须有「最小尺寸 / -/// 宽屏」两档,且必须是**真外壳**(自绘三键 + 标题栏)——`pages_demo` example 的标题栏是留白带, -/// 出不了「三键位置」那张图。语义与 `ULL_START_ROUTE` 一致:只影响这一次进程。 -pub const WINDOW_SIZE_ENV: &str = "ULL_WINDOW_SIZE"; - /// `ULL_ACCEPTANCE` 是否打开(缺省关;`0` / `off` / `false` / `no` 也算关)。 #[must_use] pub fn enabled() -> bool { @@ -114,97 +112,6 @@ pub fn theme_text(source: &str, flavor: Option<&str>, follows_system: bool) -> S ) } -// ---------------------------------------------------------------- [window] - -/// `[window]` 的**请求值**一行(实际值见模块文档的表:脚本从 `[shot] … scale=` 与 PNG 换算)。 -/// -/// `min` / `title` 是 `Option`:`pages_demo` 那条通道没有最小尺寸与自定标题,这时打 `none` / -/// `<默认>` —— 填个 `0x0` 会让人以为是「最小尺寸是 0」,那是另一种假绿。 -pub fn report_window( - size: (f32, f32), - min: Option<(f32, f32)>, - scale: Option, - size_source: &str, - title: Option<&str>, -) { - eprintln!("{}", window_text(size, min, scale, size_source, title)); -} - -/// 见 [`report_window`]。 -#[must_use] -pub fn window_text( - size: (f32, f32), - min: Option<(f32, f32)>, - scale: Option, - size_source: &str, - title: Option<&str>, -) -> String { - let min = min.map_or_else(|| "none".to_owned(), |(w, h)| format!("{w}x{h}")); - let title = title.map_or_else(|| "<默认>".to_owned(), |text| format!("\"{text}\"")); - format!( - "[window] requested size={}x{} min={min} resizable=true decorations=false transparent=false \ - scale_requested={} size_source={size_source} title={title}", - size.0, - size.1, - scale.map_or_else(|| "system".to_owned(), |value| value.to_string()), - ) -} - -/// 解析 `ULL_WINDOW_SIZE`(`1520x900` / `1520X900` / `1520×900`)。 -/// -/// 返回 `None` = 不是这个格式(调用方保留默认尺寸并打一行警告,**不**静默忽略)。 -#[must_use] -pub fn parse_window_size(raw: &str) -> Option<(f32, f32)> { - let trimmed = raw.trim(); - let (width, height) = trimmed - .split_once(['x', 'X', '×', '*']) - .map(|(w, h)| (w.trim(), h.trim()))?; - let width: f32 = width.parse().ok()?; - let height: f32 = height.parse().ok()?; - (width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) - .then_some((width, height)) -} - -/// 尺寸覆盖 → 实际要开多大 + **尺寸来源**(进 `[window] … size_source=`)。 -/// -/// 三种来源:`default`(没设 / 设了但解析不了)与 `ULL_WINDOW_SIZE`(生效)。 -/// 小于最小尺寸时夹到最小并标 `:clamped-to-min`——iced 的 `min_size` 本来也会拦住, -/// 但那样日志会说「要求 640x480」而实际是 880x560,是典型的「日志说请求、报告当结果」。 -#[must_use] -pub fn resolve_window_size( - raw: Option<&str>, - default: (f32, f32), - min: (f32, f32), -) -> ((f32, f32), &'static str) { - let Some(raw) = raw else { - return (default, "default"); - }; - let Some((width, height)) = parse_window_size(raw) else { - return (default, "ULL_WINDOW_SIZE:invalid"); - }; - - let clamped = (width.max(min.0), height.max(min.1)); - if clamped == (width, height) { - (clamped, "ULL_WINDOW_SIZE") - } else { - (clamped, "ULL_WINDOW_SIZE:clamped-to-min") - } -} - -/// 窗尺寸(含来源)从进程环境取一遍——`app::boot` 与测试共用同一条路径。 -#[must_use] -pub fn window_size(default: (f32, f32), min: (f32, f32)) -> ((f32, f32), &'static str) { - let raw = std::env::var(WINDOW_SIZE_ENV).ok(); - let resolved = resolve_window_size(raw.as_deref(), default, min); - if resolved.1.ends_with("invalid") { - eprintln!( - "[window] {WINDOW_SIZE_ENV}={:?} 解析不了(要 1520x900 这种),按默认尺寸跑", - raw.unwrap_or_default() - ); - } - resolved -} - // ---------------------------------------------------------------- [backend] /// `[backend] plan`:这一次准备试**哪些**后端(单项 + 本 crate 的兜底,不是 iced 的候选回退)。 @@ -249,8 +156,21 @@ pub fn backend_started_text(primary: Kind, elapsed: Duration) -> String { /// - `respawn`:交互启动换了进程用兜底后端重开(子进程自己再打一遍 plan/final); /// - `refuse`:取证启动(`--screenshot`)拒绝降级出图,**没有图**; /// - `none`:没有兜底(`--renderer=cpu` / 没编 wgpu),原样失败。 -pub fn report_backend_failed(primary: Kind, fallback: Option, via: &str, error: &str) { - eprintln!("{}", backend_failed_text(primary, fallback, via, error)); +/// +/// `final` 是**终态**:`refuse` / `none` 两条路什么都没跑起来 ⇒ `final=none`(不是兜底名)。 +/// 第一版让 `final=` 与 `fallback=` 装同一个值,于是 `[backend] final=tiny-skia result=failed` +/// 会被 REPORT 的「后端实际选中」照抄成「这次跑的是 tiny-skia」——报告说谎(M98 r1 p3-4)。 +pub fn report_backend_failed( + primary: Kind, + fallback: Option, + final_kind: Option, + via: &str, + error: &str, +) { + eprintln!( + "{}", + backend_failed_text(primary, fallback, final_kind, via, error) + ); } /// 见 [`report_backend_failed`]。 @@ -258,21 +178,26 @@ pub fn report_backend_failed(primary: Kind, fallback: Option, via: &str, e pub fn backend_failed_text( primary: Kind, fallback: Option, + final_kind: Option, via: &str, error: &str, ) -> String { - let final_name = fallback.map_or("none", Kind::name); + let fallback_name = fallback.map_or("none", Kind::name); + let final_name = final_kind.map_or("none", Kind::name); // `error="…"` 必须是**格式良好的单个字段**:具名错误里常自带双引号(wgpu 的 Debug 输出), // 原样塞进去会把这一行撕成两半,脚本按 `error="` 取值就只能取到前半截。 let error = error.replace('"', "'").replace(['\n', '\r'], " "); format!( - "[backend] final={final_name} result=failed primary={} fallback={final_name} via={via} \ - error=\"{error}\"", + "[backend] final={final_name} result=failed primary={} fallback={fallback_name} \ + via={via} error=\"{error}\"", primary.name(), ) } -/// 起步失败后去了哪(日志字段 `via=`;与 `app::boot::OnFailure` 同一套取值)。 +/// 起步失败后去了哪(日志字段 `via=`;与 `app::boot::fallback::OnFailure` 同一套取值)。 +/// +/// 放在这里而不是 `app/boot/fallback.rs`:`mod boot` 在 `app/mod.rs` 里是私有的(那份文件不在 +/// M98 的 scope),`pages::harness` 够不到它,而两个调用点必须用**同一个**判据。 #[must_use] pub fn failure_via(has_fallback: bool, evidence: bool) -> &'static str { match (has_fallback, evidence) { @@ -283,6 +208,9 @@ pub fn failure_via(has_fallback: bool, evidence: bool) -> &'static str { } /// 起步结果 → `[backend] final` 一行 +(验收模式)`[perf] session` 一行。 +/// +/// `final_kind` 的口径:`respawn` 时是**兜底**(进程即将带着它重开),`refuse` / `none` 时是 +/// `None`(什么都没跑起来)——见 [`backend_failed_text`]。成功时恒为 `primary`。 pub fn report_outcome( decision: &Decision, error: Option<&str>, @@ -290,28 +218,42 @@ pub fn report_outcome( mode: &str, elapsed: Duration, ) { + let final_kind = match via { + "respawn" => decision.fallback, + _ => None, + }; match error { None => report_backend_started(decision.primary, elapsed), - Some(message) => report_backend_failed(decision.primary, decision.fallback, via, message), + Some(message) => report_backend_failed( + decision.primary, + decision.fallback, + final_kind, + via, + message, + ), } if enabled() { report_perf_session(mode, elapsed, error.is_none()); } } -// ---------------------------------------------------------------- [perf] - /// `[perf] boot`:启动三段(探测 / 字体 / 到 `run()` 之前)的实测毫秒数。 -pub fn report_perf_boot(probe: Duration, font: Duration, to_run: Duration) { +/// +/// `probe = None` ⇒ 打 `probe=skipped`。**不许拿 `0ms` 冒充一次实测**(M98 r1 p3-1:降级重开那一轮 +/// 没计时却报了 0,而报告说「[perf] 全是 Instant 实测」)。 +pub fn report_perf_boot(probe: Option, font: Duration, to_run: Duration) { eprintln!("{}", perf_boot_text(probe, font, to_run)); } /// 见 [`report_perf_boot`]。 #[must_use] -pub fn perf_boot_text(probe: Duration, font: Duration, to_run: Duration) -> String { +pub fn perf_boot_text(probe: Option, font: Duration, to_run: Duration) -> String { format!( - "[perf] boot probe={}ms font={}ms to_run={}ms", - probe.as_millis(), + "[perf] boot probe={} font={}ms to_run={}ms", + probe.map_or_else( + || "skipped".to_owned(), + |value| format!("{}ms", value.as_millis()) + ), font.as_millis(), to_run.as_millis() ) diff --git a/crates/app-iced/src/renderer/acceptance/tests.rs b/crates/app-iced/src/renderer/acceptance/tests.rs index dd36bea..360fadb 100644 --- a/crates/app-iced/src/renderer/acceptance/tests.rs +++ b/crates/app-iced/src/renderer/acceptance/tests.rs @@ -31,25 +31,33 @@ fn probe_of(adapters: &[(&str, &'static str)]) -> Probe { /// 抓不住:窗口真的开成了多大(由验收脚本从 `[shot] … scale=` 与 PNG 像素换算)。 #[test] fn window_line_is_labelled_as_a_request() { - let line = window_text( - (1180.0, 760.0), - Some((880.0, 560.0)), - None, - "default", - Some("Unlucky Launcher"), - ); + let facts = WindowFacts { + size: (1180.0, 760.0), + min: Some((880.0, 560.0)), + resizable: true, + decorations: false, + transparent: false, + }; + let line = window_text(&facts, None, "default", Some("Unlucky Launcher")); assert!( line.starts_with("[window] requested size=1180x760"), "{line}" ); assert!(line.contains("min=880x560"), "{line}"); + assert!(line.contains("resizable=true"), "{line}"); assert!(line.contains("decorations=false"), "{line}"); + assert!(line.contains("transparent=false"), "{line}"); assert!(line.contains("scale_requested=system"), "{line}"); assert!(line.contains("size_source=default"), "{line}"); assert!(line.contains("title=\"Unlucky Launcher\""), "{line}"); // `pages_demo` 那条通道没有最小尺寸 / 自定标题:必须写 none / <默认>,不许编 0x0 - let harness = window_text((1180.0, 760.0), None, Some(1.0), "pages_demo", None); + let harness = window_text( + &WindowFacts { min: None, ..facts }, + Some(1.0), + "pages_demo", + None, + ); assert!(harness.contains("min=none"), "{harness}"); assert!(harness.contains("title=<默认>"), "{harness}"); assert!(harness.contains("scale_requested=1"), "{harness}"); @@ -177,16 +185,16 @@ fn backend_final_lines_separate_started_from_failed() { ); assert!(started.contains("evidence=probe-single-item"), "{started}"); + // refuse:**什么都没跑起来** ⇒ final=none(不是兜底名,否则报告会把「这次用的是 tiny-skia」当真) let refused = backend_failed_text( Kind::Wgpu, Some(Kind::TinySkia), + None, "refuse", "metal support not compiled in", ); - assert!( - refused.contains("final=tiny-skia result=failed"), - "{refused}" - ); + assert!(refused.contains("final=none result=failed"), "{refused}"); + assert!(refused.contains("fallback=tiny-skia"), "{refused}"); assert!(refused.contains("primary=wgpu"), "{refused}"); assert!(refused.contains("via=refuse"), "{refused}"); assert!( @@ -194,13 +202,25 @@ fn backend_final_lines_separate_started_from_failed() { "具名错误必须原样出现在同一行里:{refused}" ); - let none = backend_failed_text(Kind::TinySkia, None, "none", "boom"); + let none = backend_failed_text(Kind::TinySkia, None, None, "none", "boom"); assert!(none.contains("final=none result=failed"), "{none}"); + // respawn:进程即将带着兜底重开 ⇒ final=<兜底> + let respawned = backend_failed_text( + Kind::Wgpu, + Some(Kind::TinySkia), + Some(Kind::TinySkia), + "respawn", + "x", + ); + assert!(respawned.contains("final=tiny-skia"), "{respawned}"); + assert!(respawned.contains("via=respawn"), "{respawned}"); + // 具名错误自带双引号时,`error="…"` 仍必须是**格式良好的单个字段**(否则脚本只能取到前半截) let quoted = backend_failed_text( Kind::Wgpu, Some(Kind::TinySkia), + None, "refuse", "GraphicsAdapterNotFound { backend: \"wgpu\" }", ); @@ -219,11 +239,14 @@ fn backend_final_lines_separate_started_from_failed() { #[test] fn perf_lines_carry_measured_millis() { let boot = perf_boot_text( - Duration::from_millis(650), + Some(Duration::from_millis(650)), Duration::from_millis(120), Duration::from_millis(780), ); assert_eq!(boot, "[perf] boot probe=650ms font=120ms to_run=780ms"); + // 没探测 ⇒ skipped,不是 0ms(M98 r1 p3-1:拿 0 冒充实测) + let skipped = perf_boot_text(None, Duration::from_millis(120), Duration::from_millis(780)); + assert!(skipped.contains("probe=skipped"), "{skipped}"); let session = perf_session_text("screenshot", Duration::from_millis(1234), true); assert!(session.contains("mode=screenshot elapsed=1234ms result=ok")); @@ -281,27 +304,6 @@ fn theme_source_follows_the_capture_flavor_order() { assert!(system.contains("flavor=pending"), "{system}"); } -/// 抓得住:`via` 的三档与判据表第 8/9 行一致(有兜底 + 取证启动 ⇒ refuse,**不是** respawn)。 -#[test] -fn failure_via_matches_the_two_paths_of_the_criteria_table() { - assert_eq!( - failure_via(true, false), - "respawn", - "交互启动 + 有兜底 ⇒ 换进程重开" - ); - assert_eq!( - failure_via(true, true), - "refuse", - "取证启动 + 有兜底 ⇒ 拒绝出图" - ); - assert_eq!(failure_via(false, false), "none", "没兜底 ⇒ 原样失败"); - assert_eq!( - failure_via(false, true), - "none", - "没兜底时也不该冒出 refuse 的提示" - ); -} - /// 抓得住:`[theme]` 的 `follows_system=true` 必须同时是 `flavor=pending` /// (系统主题异步到达;写死一个值就是猜)。 #[test] @@ -324,3 +326,70 @@ fn acceptance_switch_is_off_by_default() { assert_eq!(ACCEPTANCE_ENV, "ULL_ACCEPTANCE"); assert_eq!(WINDOW_SIZE_ENV, "ULL_WINDOW_SIZE"); } + +/// 抓得住:`theme_source()`(日志用)与 `Options::capture_flavor()`(真取值口)**两处优先级漂了**—— +/// 漂了就会打出错的 `source=`,而图上的主题才是真的(M98 r1 p3-5:两份实现,没有交叉测试)。 +/// 抓不住:系统主题异步到达之后的值(那要 `[theme] 系统主题 = …` 那一行)。 +#[test] +fn theme_source_agrees_with_the_only_flavor_gate() { + use crate::cli::Options; + + let cases: [&[&str]; 5] = [ + &[], + &["--screenshot", "out.png"], + &["--screenshot", "out.png", "--theme=dark"], + &[ + "--screenshot", + "out.png", + "--theme=dark", + "--screenshot-theme=light", + ], + &["--theme=dark"], + ]; + + for args in cases { + let options = Options::parse(args.iter().map(|arg| (*arg).to_owned())).expect("参数合法"); + let (source, follows) = theme_source( + options.screenshot_theme.is_some(), + options.theme.is_some(), + options.screenshot.is_some(), + ); + let flavor = options.capture_flavor(); + + assert_eq!( + follows, + flavor.is_none(), + "{}:follows_system 与 capture_flavor().is_none() 必须一致", + args.join(" ") + ); + // 非「跟随系统」时,来源标签必须能解释 capture_flavor 给出的那个值 + match source { + "screenshot-theme" => assert_eq!(flavor, options.screenshot_theme), + "theme" => assert_eq!(flavor, options.theme), + "default-light" => assert_eq!(flavor, Some(unlucky_design::color::SchemeFlavor::Light)), + "system" => assert_eq!(flavor, None), + other => panic!("没料到的来源 {other}"), + } + } +} + +/// 抓得住:`via` 的三档与判据表第 8/9 行一致(有兜底 + 取证启动 ⇒ `refuse`,**不是** `respawn`)。 +#[test] +fn failure_via_matches_the_two_paths_of_the_criteria_table() { + assert_eq!( + failure_via(true, false), + "respawn", + "交互启动 + 有兜底 ⇒ 换进程重开" + ); + assert_eq!( + failure_via(true, true), + "refuse", + "取证启动 + 有兜底 ⇒ 拒绝出图" + ); + assert_eq!(failure_via(false, false), "none", "没兜底 ⇒ 原样失败"); + assert_eq!( + failure_via(false, true), + "none", + "没兜底时也不该冒出 refuse 的提示" + ); +} diff --git a/crates/app-iced/src/renderer/acceptance/window.rs b/crates/app-iced/src/renderer/acceptance/window.rs new file mode 100644 index 0000000..51b2ed2 --- /dev/null +++ b/crates/app-iced/src/renderer/acceptance/window.rs @@ -0,0 +1,142 @@ +//! `[window]`:**本进程给窗口的那些设置**(不是我们从别处猜的)。 +//! +//! 唯一产地是 `app/boot/diagnostics.rs::window_settings`(`pages_demo` 通道自己也有一份, +//! 它没有最小尺寸与自定标题)。这里只做「从 `window::Settings` 抽字段 → 排版」。 +//! +//! **别再把这些字段写进格式化串**:M98 r1 p2-1 就是这么错的——日志说 `decorations=false`, +//! 而真实 `settings` 可以改成 `true` 而不惊动任何断言(踩坑记录 U1)。 + +/// 窗口尺寸覆盖(`ULL_WINDOW_SIZE=1520x900`)。 +/// +/// 为什么是环境变量而不是 `--width`:`cli.rs` 不在 M98 的 scope,而验收矩阵必须有「最小尺寸 / +/// 宽屏」两档,且必须是**真外壳**(自绘三键 + 标题栏)——`pages_demo` example 的标题栏是留白带, +/// 出不了「三键位置」那张图。语义与 `ULL_START_ROUTE` 一致:只影响这一次进程。 +pub const WINDOW_SIZE_ENV: &str = "ULL_WINDOW_SIZE"; + +// ---------------------------------------------------------------- [window] + +/// 窗口事实:**从真实的 `window::Settings` 抽出来**的那几个字段(唯一产地见 +/// `app/boot/diagnostics.rs` 的 `window_settings`)。 +/// +/// 为什么要有这个结构:M98 r1 p2-1 —— 第一版把 `resizable=true decorations=false transparent=false` +/// 写在格式化串里,与真实 `settings` 无绑定;定向变异把 `settings.decorations` 改成 `true`, +/// 371 个测试全绿,而日志会与 `[caps]` 互相矛盾(踩坑记录 U1)。现在这些字段只能从 +/// [`WindowFacts::from_settings`] 来,没有手抄的入口。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct WindowFacts { + /// 窗口尺寸(逻辑像素)。 + pub size: (f32, f32), + /// 最小尺寸(`None` = 没设)。 + pub min: Option<(f32, f32)>, + /// 可缩放。 + pub resizable: bool, + /// 系统装饰(`false` ⇒ 自绘三键)。 + pub decorations: bool, + /// 整窗透明。 + pub transparent: bool, +} + +impl WindowFacts { + /// 从**真正交给 iced/winit 的那个** `window::Settings` 抽字段。 + #[must_use] + pub fn from_settings(settings: &iced::window::Settings) -> Self { + Self { + size: (settings.size.width, settings.size.height), + min: settings.min_size.map(|size| (size.width, size.height)), + resizable: settings.resizable, + decorations: settings.decorations, + transparent: settings.transparent, + } + } +} + +/// `[window]` 的**请求值**一行(实际值见模块文档的表:脚本从 `[shot] … scale=` 与 PNG 换算)。 +/// +/// `title` 是 `Option`:`pages_demo` 那条通道没有自定标题,打 `<默认>` —— 编一个空串会让 +/// 「标题是什么」变成猜的。 +pub fn report_window( + facts: &WindowFacts, + scale: Option, + size_source: &str, + title: Option<&str>, +) { + eprintln!("{}", window_text(facts, scale, size_source, title)); +} + +/// 见 [`report_window`]。 +#[must_use] +pub fn window_text( + facts: &WindowFacts, + scale: Option, + size_source: &str, + title: Option<&str>, +) -> String { + let min = facts + .min + .map_or_else(|| "none".to_owned(), |(w, h)| format!("{w}x{h}")); + let title = title.map_or_else(|| "<默认>".to_owned(), |text| format!("\"{text}\"")); + format!( + "[window] requested size={}x{} min={min} resizable={} decorations={} transparent={} scale_requested={} size_source={size_source} title={title}", + facts.size.0, + facts.size.1, + facts.resizable, + facts.decorations, + facts.transparent, + scale.map_or_else(|| "system".to_owned(), |value| value.to_string()), + ) +} + +/// 解析 `ULL_WINDOW_SIZE`(`1520x900` / `1520X900` / `1520×900`)。 +/// +/// 返回 `None` = 不是这个格式(调用方保留默认尺寸并打一行警告,**不**静默忽略)。 +#[must_use] +pub fn parse_window_size(raw: &str) -> Option<(f32, f32)> { + let trimmed = raw.trim(); + let (width, height) = trimmed + .split_once(['x', 'X', '×', '*']) + .map(|(w, h)| (w.trim(), h.trim()))?; + let width: f32 = width.parse().ok()?; + let height: f32 = height.parse().ok()?; + (width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) + .then_some((width, height)) +} + +/// 尺寸覆盖 → 实际要开多大 + **尺寸来源**(进 `[window] … size_source=`)。 +/// +/// 三种来源:`default`(没设 / 设了但解析不了)与 `ULL_WINDOW_SIZE`(生效)。 +/// 小于最小尺寸时夹到最小并标 `:clamped-to-min`——iced 的 `min_size` 本来也会拦住, +/// 但那样日志会说「要求 640x480」而实际是 880x560,是典型的「日志说请求、报告当结果」。 +#[must_use] +pub fn resolve_window_size( + raw: Option<&str>, + default: (f32, f32), + min: (f32, f32), +) -> ((f32, f32), &'static str) { + let Some(raw) = raw else { + return (default, "default"); + }; + let Some((width, height)) = parse_window_size(raw) else { + return (default, "ULL_WINDOW_SIZE:invalid"); + }; + + let clamped = (width.max(min.0), height.max(min.1)); + if clamped == (width, height) { + (clamped, "ULL_WINDOW_SIZE") + } else { + (clamped, "ULL_WINDOW_SIZE:clamped-to-min") + } +} + +/// 窗尺寸(含来源)从进程环境取一遍——`app::boot` 与测试共用同一条路径。 +#[must_use] +pub fn window_size(default: (f32, f32), min: (f32, f32)) -> ((f32, f32), &'static str) { + let raw = std::env::var(WINDOW_SIZE_ENV).ok(); + let resolved = resolve_window_size(raw.as_deref(), default, min); + if resolved.1.ends_with("invalid") { + eprintln!( + "[window] {WINDOW_SIZE_ENV}={:?} 解析不了(要 1520x900 这种),按默认尺寸跑", + raw.unwrap_or_default() + ); + } + resolved +} diff --git a/crates/app-iced/src/renderer/mod.rs b/crates/app-iced/src/renderer/mod.rs index 40ba843..90ea616 100644 --- a/crates/app-iced/src/renderer/mod.rs +++ b/crates/app-iced/src/renderer/mod.rs @@ -72,6 +72,18 @@ //! | [`log_bridge`] | 把 `iced_wgpu` / `wgpu` 的 `log` 输出转到 stderr(`Selected: AdapterInfo{…}` 这行就是 GPU 生效的实证) | 开(`ULL_GPU_LOG=off` 可关) | //! | [`acceptance`] | **验收诊断(M98)**:`[env]` `[backend]` `[window]` `[font] chain` `[theme]` `[perf]` `[i18n]` `[caps]` 八类里本 crate 能观测的那部分,固定前缀、单行可 grep | 便宜的行恒开;贵的(适配器枚举)由 `ULL_ACCEPTANCE=1` 打开 | //! +//! ### 「单项 ⇒ 没有静默回退」这条推理的依据(M98 r1 复审挖到源码层) +//! +//! `iced_renderer` 的 `fallback::Compositor` 拿到 `ICED_BACKEND` 后**仍会把每个候选依次拿去试 +//! wgpu 和 tiny-skia**,两个子渲染器收同一个 backend 字符串,所以「单项」本身并不阻止它去试 +//! tiny-skia。真正挡住它的是 `iced_tiny_skia` 的 `with_backend`:它只接受 +//! `None | "tiny-skia" | "tiny_skia"`,`Some("wgpu")` 直接返回 `GraphicsAdapterNotFound`。 +//! 于是 `ICED_BACKEND=wgpu` 这条路**不可能**落到 tiny-skia:起不来就两项都失败、返回 +//! `GraphicsCreationFailed`(判据表第 8/9 行接住它)。 +//! +//! 这条依赖 iced 的实现细节,所以:① `iced = "=0.14.0"` 精确钉版本;② 验收脚本另有 +//! `[gpu] …Selected: AdapterInfo` 这条**不依赖本 crate 推理**的实证断言。升级 iced 时这两条都要重看。 +//! //! **本模块自己只回答「判据」**;「结果」由三处分别取证,别混:① `[backend] final=`(本 crate 的 //! 起步结果,判据是单项 `ICED_BACKEND`);② `[gpu] iced_wgpu: …Selected:`(iced 自己打的, //! GPU 真生效的实证);③ `[shot] … renderer=`(这张图是哪个后端出的)。