From 31cdfd5bbd56b7a2f95261ba9319b369673f2407 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 23:16:17 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(renderer):=20M97=20=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E5=88=A4=E6=8D=AE=20=E2=80=94=E2=80=94=20=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E9=99=8D=E7=BA=A7=E6=94=B9=E5=85=B7=E5=90=8D=E9=94=99=E8=AF=AF?= =?UTF-8?q?=EF=BC=8C=E5=8F=96=E8=AF=81=E9=80=9A=E9=81=93=E6=8B=92=E7=BB=9D?= =?UTF-8?q?=E9=99=8D=E7=BA=A7=E5=87=BA=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M97 的入口是一份 macOS M1 真机报告:探测认出了 Apple M1(adapters=1 / hardware=true),日志却写着 `renderer = auto (wgpu -> tiny-skia)`,看起来 像在跑 CPU。读源码发现那行是**常量**:`probe.rs` 按 `ICED_BACKEND` 未设就把 `"auto (wgpu -> tiny-skia)"` 打出来,与实际后端无关。真正的失败被 iced 吞掉: `iced_renderer-0.14.0/src/fallback.rs` 的 `Compositor::with_backend` 把 wgpu 的 错误装进 `errors` 向量,tiny-skia 一成功就整体丢弃。 ## 判据改为单项,不再交给 iced 回退 `ICED_BACKEND` 现在**永远是单项**(`wgpu` / `tiny-skia`):能跑起来就必定是它; 起不来就必然是 `iced::Error::GraphicsCreationFailed` 里的具名错误。判据是纯函数 `renderer::decide(request, probe, gpu_compiled)`,落在 `renderer/decision.rs`, 表驱动测试逐行钉死 1~6 行 + 「除强制 CPU / 未编 wgpu 外必须留兜底」的守门。 判据表(条件 / 选哪个 / 日志该打什么 / 谁能验证)在 `renderer/mod.rs` 顶部。 ## 安全网改成换进程 第一版是「打印错误 → 在本进程再 run() 一次」,Windows 实测直接 panic: `Create event loop: RecreationAttempt`(winit 一个进程只能建一次事件循环)。 改成 `respawn`:子进程只换 `ICED_BACKEND` + `ULL_GPU_FALLBACK`,父进程透传它的 退出码。子进程看到 `ULL_GPU_FALLBACK` 必须跳过探测,否则会无限递归重开。 ## 取证通道不许自己降级 带 `--screenshot`(含 `--screen=gallery`)时 wgpu 起不来就**不出图**、退出码 1, 因为换了后端出的图会撒谎;交互启动才允许降级。每张图的日志现在打**实际**后端 (`[shot] … renderer=wgpu`),以前打的是请求值 `auto`——同一个病(在需要结果的 地方打请求)在仓库里出现了两次,这是第二次。 ## 可观测性 - `log` 桥(`renderer/log_bridge.rs`)把 `iced_wgpu` 的 `Selected: AdapterInfo{…}` 接到 stderr:这是本仓库第一次能自证「真在用 GPU」。 按 target 分级(`iced_wgpu*` Info、`wgpu*` Warn),否则 Vulkan loader 会刷 90 行噪声。 - `renderer/preflight.rs`(默认关,`ULL_PREFLIGHT=1`):开窗前拿 wgpu 的具名错误。 实测 Windows RX 6600 XT 上 took=717ms,所以不进默认路径。 - `app/backend.rs` 的档位判据跟着改成读单项;**真降级到 CPU 的那一次面板 α 现在会 正确强制 1.0**(以前静默降级后仍按 GPU 档判)。 Windows 实测:auto → wgpu + `Selected: AdapterInfo{…backend: Vulkan}` 出图; `--renderer=cpu` 逐字不变;`WGPU_BACKEND=metal` 造失败 → 具名错误 → 交互路径降级 不崩(首帧 531ms)/ 取证路径拒绝出图 exit 1。macOS Metal 路径需真机复核, 命令与期望日志行写在 `renderer/mod.rs` 的「真机验收」一节。 --- Cargo.lock | 1 + crates/app-iced/Cargo.toml | 6 + crates/app-iced/src/app/backend.rs | 120 +++++--- crates/app-iced/src/app/boot.rs | 127 ++++++++- crates/app-iced/src/app/update.rs | 4 +- crates/app-iced/src/lib.rs | 2 + crates/app-iced/src/main.rs | 5 + crates/app-iced/src/pages/harness.rs | 109 +++++--- crates/app-iced/src/probe.rs | 156 +++++------ crates/app-iced/src/renderer/decision.rs | 305 +++++++++++++++++++++ crates/app-iced/src/renderer/log_bridge.rs | 151 ++++++++++ crates/app-iced/src/renderer/mod.rs | 187 +++++++++++++ crates/app-iced/src/renderer/preflight.rs | 244 +++++++++++++++++ 13 files changed, 1260 insertions(+), 157 deletions(-) create mode 100644 crates/app-iced/src/renderer/decision.rs create mode 100644 crates/app-iced/src/renderer/log_bridge.rs create mode 100644 crates/app-iced/src/renderer/mod.rs create mode 100644 crates/app-iced/src/renderer/preflight.rs diff --git a/Cargo.lock b/Cargo.lock index 6079eaf..0526e1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6921,6 +6921,7 @@ version = "0.1.0" dependencies = [ "iced", "image", + "log", "unlucky-cli", "unlucky-design", "unlucky-i18n", diff --git a/crates/app-iced/Cargo.toml b/crates/app-iced/Cargo.toml index af2fd01..9e904b8 100644 --- a/crates/app-iced/Cargo.toml +++ b/crates/app-iced/Cargo.toml @@ -63,6 +63,12 @@ unlucky-cli = { path = "../cli", version = "0.1" } # 所以系统字体扫描留在这里(`src/fonts/cjk.rs`),本 crate 只把族名喂给它的纯函数。 unlucky-i18n = { path = "../i18n", version = "0.1" } +# M97:判据表第 8 行的取证通道。`iced_wgpu` 在真创建设备时会 `log::info!("Selected: {adapter:#?}")`, +# 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` / +# `wgpu*` 的 logger(见 `src/renderer/log_bridge.rs`),把这行捞出来。 +# 已是 iced 传递依赖树里的版本(0.4.34),不新增任何传递依赖;MIT OR Apache-2.0,许可白名单内。 +log = { version = "0.4", features = ["std"] } + # X11 / Wayland 后端与 Linux 主题检测只在 Linux 上启用:macOS 没有 X11/Wayland(走 Metal + # 系统外观),Windows 走 DX12,这三个 feature 在那边没有意义。同一个包在两处写 features 是 # **取并集**(Cargo 的 feature 解析是加法),所以 Linux 上拿到的仍是上面 5 个 + 这 3 个, diff --git a/crates/app-iced/src/app/backend.rs b/crates/app-iced/src/app/backend.rs index 3eb9d02..cfbcbae 100644 --- a/crates/app-iced/src/app/backend.rs +++ b/crates/app-iced/src/app/backend.rs @@ -7,38 +7,50 @@ //! 请求是 `Auto`,实际是 tiny-skia(CPU)——如果面板 α / 玻璃的降级判据只看请求值, //! D36 明写的「CPU 档面板 α 强制 1.0」**恰好在它最该生效的场景失效**(复审 r1 P2-2)。 //! -//! 所以判据统一走 [`effective_backend`]:它把 `ICED_BACKEND` 的候选列表翻译回 [`Backend`]。 +//! 所以判据统一走 [`effective_backend`]:它把 `ICED_BACKEND` 翻译回 [`Backend`]。 //! 这里是**唯一**的翻译点,别在别处再读一遍环境变量。 //! +//! ## M97 之后这条链是准的(以前不是) +//! +//! 以前 `ICED_BACKEND` 可能是**候选列表**(`wgpu,tiny-skia`),运行期 wgpu 静默回退到 +//! tiny-skia 时这个变量还停在 wgpu ⇒ 本模块按 **GPU 档**判 ⇒ **半透明在 CPU 渲染器上照开**。 +//! 也就是说「静默降级」不只让日志说谎,还让 D36 的降级判据一起说谎。 +//! +//! 现在 `ICED_BACKEND` **永远是单项**([`crate::renderer::Kind`]),而且 `app::boot` 的重试 +//! 在降级重开前会把它改成 `tiny-skia`,于是:**真降级到 CPU 的那一次,面板 α 会正确地强制 1.0**。 +//! //! ## 已知边界(诚实登记) //! -//! `ICED_BACKEND="wgpu,tiny-skia"` 表示「优先 wgpu,拿不到适配器再回退 tiny-skia」。运行期到底 -//! 选了谁只有渲染器自己知道,本 crate 拿不到(iced 0.14 没有公开的「当前后端」查询)。 -//! 这种机器按 **GPU 档**判(面板 α 生效);如果 wgpu 在运行期回退到 tiny-skia,面板 α 会 -//! 在 CPU 渲染器上生效——成本比 D27 的顾虑略高,但不会画错。要彻底收口得让 iced 暴露实际后端。 +//! `iced` 0.14 没有公开的「当前后端」查询,所以本模块读的仍是**我们请求的那一项**。 +//! 单项 + 重试([`crate::renderer`] 判据表第 8 行)把「请求 ≠ 实际」的窗口压到只剩一种: +//! 重试那次自己也失败——那时候进程会带着具名错误退出,不会带着错的档位继续画。 +use crate::renderer::Kind; use crate::Backend; -/// `ICED_BACKEND` 的候选列表 → 实际档位(纯函数,好测)。 +/// `ICED_BACKEND` 的取值 → 后端(纯函数,好测)。 /// -/// | `ICED_BACKEND` | 含义(`probe::backend_env` 的产物) | 结论 | +/// 逗号列表按**首项**解释:兼容 M97 之前写下的值(以及外部手设的实验值), +/// 但本 crate 不再产出那种值(守门测试见 `probe::tests::apply_writes_exactly_one_backend`)。 +#[must_use] +pub(super) fn parse_kind(env: &str) -> Option { + match env.trim().split(',').next()?.trim() { + "tiny-skia" => Some(Kind::TinySkia), + "wgpu" => Some(Kind::Wgpu), + _ => None, + } +} + +/// `ICED_BACKEND` → 实际档位(纯函数,好测)。 +/// +/// | `ICED_BACKEND` | 含义(`probe::apply` 的产物) | 结论 | /// |---|---|---| -/// | `tiny-skia` | 只有 CPU 候选(`--software-renderer` / 未编 wgpu / Auto 无线程硬件) | [`Backend::Cpu`] | -/// | `wgpu,tiny-skia` | 点名要 GPU(保留回退) | [`Backend::Gpu`] | -/// | `tiny-skia,wgpu` | Auto + 有 wgpu 无硬件(CPU 起步、wgpu 兜底) | [`Backend::Cpu`] | -/// | 未设 | Auto + 有硬件(交给 iced 默认顺序) | 请求值(`Auto`) | +/// | `tiny-skia` | 只有 CPU(`--renderer=cpu` / 未编 wgpu / 探测无硬件 / wgpu 失败后重开) | [`Backend::Cpu`] | +/// | `wgpu` | 判据表第 3、6 行:起步就是 GPU(起不来会打印具名错误并重开成 `tiny-skia`) | [`Backend::Gpu`] | +/// | 未设 / 别的值 | 不是本 crate 写的(外部手设 / M97 之前的旧进程) | 请求值(不猜) | #[must_use] pub(super) fn backend_from_env(request: Backend, env: Option<&str>) -> Backend { - let Some(list) = env.map(str::trim).filter(|value| !value.is_empty()) else { - return request; - }; - // 候选列表的第一项就是起步渲染器:`tiny-skia` 起步 = CPU 档(D36 的降级对象)。 - let first = list.split(',').next().unwrap_or(list).trim(); - match first { - "tiny-skia" => Backend::Cpu, - "wgpu" => Backend::Gpu, - _ => request, - } + env.and_then(parse_kind).map_or(request, Kind::tier) } /// 本进程实际生效的后端(判据 = `probe::apply` 写下的 `ICED_BACKEND`)。 @@ -47,25 +59,49 @@ pub(super) fn effective_backend(request: Backend) -> Backend { backend_from_env(request, std::env::var("ICED_BACKEND").ok().as_deref()) } +/// 本进程实际生效的**后端名**(`None` = 变量没写 / 不认识)。 +/// +/// 与 [`effective_backend`] 的区别:那个给「档位」,这个给「名字」,用于**取证日志** +/// (`[shot] … renderer=wgpu`)。两张图分辨不出 GPU/CPU 的时候,靠的就是这一行。 +#[must_use] +pub(super) fn effective_kind() -> Option { + std::env::var("ICED_BACKEND") + .ok() + .as_deref() + .and_then(parse_kind) +} + #[cfg(test)] mod tests { use super::backend_from_env; use crate::Backend; use unlucky_presenter::prefs::{PANEL_ALPHA_MIN, WINDOW_ALPHA_MIN}; - /// 抓得住:`probe::backend_env` 的每一种产物都被翻译成正确的档位(含最容易踩的 - /// 「Auto + 没独显」= `tiny-skia,wgpu` → CPU)。 - /// 抓不住:运行期 wgpu 真回退到 tiny-skia 的情况(iced 没暴露实时候选,见模块文档)。 + /// 抓得住:`probe::apply` 现在产出的**每一种单项值**都被翻译成正确的档位, + /// 且 M97 之前写下的逗号列表仍按首项解释(向后兼容)。 + /// 抓不住:运行期重试降级那一刻的时序(那由 `app::boot` 的重试保证,见 `renderer` 判据表第 8 行)。 #[test] - fn every_iced_backend_list_maps_to_a_tier() { + fn every_iced_backend_value_maps_to_a_tier() { + // 本 crate 现在只产单项(`probe::tests::apply_writes_exactly_one_backend` 守这一条)。 assert_eq!( backend_from_env(Backend::Auto, Some("tiny-skia")), - Backend::Cpu + Backend::Cpu, + "M97 判据表第 5 行:探测无硬件 ⇒ CPU 档" ); assert_eq!( - backend_from_env(Backend::Auto, Some("tiny-skia,wgpu")), + backend_from_env(Backend::Auto, Some("wgpu")), + Backend::Gpu, + "M97 判据表第 6 行:有硬件 ⇒ GPU 档" + ); + assert_eq!( + backend_from_env(Backend::Cpu, Some("tiny-skia")), Backend::Cpu, - "Auto + 无硬件:CPU 起步 ⇒ CPU 档" + "强制 CPU 也是 CPU 档" + ); + // 兼容 M97 之前的逗号列表(外部手设 / 旧进程留下):按首项解释。 + assert_eq!( + backend_from_env(Backend::Auto, Some("tiny-skia,wgpu")), + Backend::Cpu ); assert_eq!( backend_from_env(Backend::Auto, Some("wgpu,tiny-skia")), @@ -75,7 +111,7 @@ mod tests { backend_from_env(Backend::Gpu, Some("wgpu,tiny-skia")), Backend::Gpu ); - // 未设 / 空白:保持请求值(Auto + 有硬件 ⇒ 交给 iced,按 GPU 档判)。 + // 未设 / 空白:保持请求值(不是本 crate 写的,不猜)。 assert_eq!(backend_from_env(Backend::Auto, None), Backend::Auto); assert_eq!(backend_from_env(Backend::Auto, Some(" ")), Backend::Auto); // 不认识的取值不猜:保留请求值。 @@ -94,7 +130,7 @@ mod tests { fn auto_with_a_software_renderer_forces_the_panel_opaque() { use crate::pages::appearance::alpha::{effective_glass, effective_panel_alpha}; - let backend = backend_from_env(Backend::Auto, Some("tiny-skia,wgpu")); + let backend = backend_from_env(Backend::Auto, Some("tiny-skia")); assert_eq!(backend, Backend::Cpu); assert_eq!( effective_panel_alpha(backend, 0.55), @@ -106,8 +142,30 @@ mod tests { assert!(crate::platform::effective_window_alpha(backend, 0.8).is_some()); // 反方向:GPU 档不做这个降级(否则「强制」就没意义了)。 - let gpu = backend_from_env(Backend::Auto, Some("wgpu,tiny-skia")); + let gpu = backend_from_env(Backend::Auto, Some("wgpu")); assert_eq!(effective_panel_alpha(gpu, 0.55), 0.55); assert!((PANEL_ALPHA_MIN - 0.4).abs() < 1e-9 && (WINDOW_ALPHA_MIN - 0.08).abs() < 1e-9); } + + /// **M97 的新守门**:wgpu 失败重开成 `tiny-skia` 之后,档位必须跟着变成 CPU 档。 + /// + /// 这是修之前做不到的一条:那时变量停在 `wgpu,tiny-skia`,静默回退到 tiny-skia 之后 + /// 判据仍然读 `Gpu` ⇒ 半透明在 CPU 渲染器上照开。现在 `app::boot` 重开前会改写这个值, + /// 这条测试钉的是「改写成 tiny-skia 之后判据确实翻转」。 + #[test] + fn retry_fallback_flips_the_tier_to_cpu() { + use crate::pages::appearance::alpha::effective_panel_alpha; + + let before = backend_from_env(Backend::Auto, Some("wgpu")); + assert_eq!(before, Backend::Gpu); + assert_eq!(effective_panel_alpha(before, 0.55), 0.55); + + let after = backend_from_env(Backend::Auto, Some("tiny-skia")); + assert_eq!(after, Backend::Cpu); + assert_eq!( + effective_panel_alpha(after, 0.55), + 1.0, + "D36:真降级到 CPU 后,面板 α 必须强制 1.0" + ); + } } diff --git a/crates/app-iced/src/app/boot.rs b/crates/app-iced/src/app/boot.rs index ae5b7db..66491f6 100644 --- a/crates/app-iced/src/app/boot.rs +++ b/crates/app-iced/src/app/boot.rs @@ -1,4 +1,13 @@ //! 启动:后端探测 → 字体求值 → `iced::application(..).run()`。 +//! +//! ## M97:`run()` 为什么要重试 +//! +//! `ICED_BACKEND` 现在永远是**单项**(见 [`crate::renderer`] 的判据表)。单项的代价是 +//! iced 不再自己做回退,于是「wgpu 起不来」会变成 `iced::Error::GraphicsCreationFailed` +//! ——**这正是我们要的**:那里面装着 wgpu 的具名错误(哪个 backend、卡在 adapter 还是 device)。 +//! 拿到它以后打印、然后换 `tiny-skia` 重开一次,安全网照样在,但降级不再是静默的。 +//! +//! 重试只在起步项真的失败时发生,而且**只重试一次**;字体栈不重算(那是启动最贵的一段)。 use std::time::Instant; @@ -9,6 +18,7 @@ use unlucky_design::color::SchemeFlavor; use crate::cli::Options; use crate::fonts; use crate::probe; +use crate::renderer::{self, Decision, Kind}; use crate::theme::Families; use super::message::Message; @@ -19,7 +29,7 @@ use super::{MIN_SIZE, WINDOW_SIZE}; pub(crate) fn run(options: Options) -> iced::Result { let _ = STARTED.set(Instant::now()); - boot_probe(&options); + let decision = boot_probe(&options); let font_started = Instant::now(); let resolved = fonts::resolve(); @@ -41,6 +51,92 @@ pub(crate) fn run(options: Options) -> iced::Result { eprintln!("[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)"); } let flavor = options.capture_flavor().unwrap_or(SchemeFlavor::Light); + + // 起步后端起不来时**换一个进程**重开(winit 不允许在本进程重建事件循环,见 + // `renderer::run_once` 的文档)。具名原因在换进程之前就打到同一个 stderr 上了。 + match launch(&options, families, flavor, decision.primary) { + Err(iced::Error::GraphicsCreationFailed(error)) => { + renderer::report_failure(decision.primary, &error); + match decision.fallback { + // 交互启动:安全网优先,降级重开,用户还能用。 + Some(fallback) if options.screenshot.is_none() => { + respawn(decision.primary, fallback) + } + // 取证启动(`--screenshot`):**拒绝降级出图**。图是要拿去下结论的, + // 换了后端出的图会撒谎——以前就是这么把 CPU 截图当 GPU 结论用的。 + Some(_) => { + eprintln!( + "[gpu] 拒绝降级出图:本次是取证启动(--screenshot {}),换后端出的图会撒谎。", + options + .screenshot + .as_ref() + .map_or_else(String::new, |path| path.display().to_string()) + ); + eprintln!( + "[gpu] 想用 CPU 出图请显式加 --renderer=cpu / --software-renderer(CI 就是这么跑的)。" + ); + Err(iced::Error::GraphicsCreationFailed(error)) + } + None => Err(iced::Error::GraphicsCreationFailed(error)), + } + } + other => other, + } +} + +/// 安全网:起一个**新进程**用 `fallback` 后端重开,然后带着它的退出码退出。 +/// +/// 为什么必须是新进程:winit 一个进程只能建一次事件循环 +/// (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)。 +/// +/// 只在**交互启动**时走到这里;取证启动(`--screenshot`)宁可红着退出也不换后端出图 +/// (判据表第 8 行 vs 第 9 行——**这两条路的策略不同,别合成一条**)。 +/// +/// ## `ULL_GPU_FALLBACK` 是防自杀循环的唯一闸门 +/// +/// 子进程看到 [`renderer::FALLBACK_ENV`] **必须跳过探测**(`boot_probe` 的第一段),换成 +/// `tiny-skia` 直接跑;否则子进程会算出和父进程一样的结论(auto + 有硬件 → wgpu)、再失败、 +/// 再 respawn —— **无限递归重开**。这种循环极难复现也极难诊断(每次重开都像是「刚刚启动」), +/// 所以闸门放在探测入口的第一个分支,不放在 respawn 那一侧。 +fn respawn(failed: Kind, fallback: Kind) -> ! { + let exe = std::env::current_exe().unwrap_or_else(|err| { + eprintln!("[gpu] 降级重开失败:拿不到自身路径({err})"); + std::process::exit(2); + }); + eprintln!( + "[gpu] 降级 → {}({} 已失败,原因见上两行;换一个进程重开:winit 不允许同进程建两次事件循环)。\ + 下一次的日志从 `[probe] 本次是降级重开` 开始。", + fallback.name(), + failed.name() + ); + + let status = std::process::Command::new(exe) + .args(std::env::args().skip(1)) + .env(renderer::FALLBACK_ENV, fallback.env_value()) + .env("ICED_BACKEND", fallback.env_value()) + .status(); + + // 退出码**透传**子进程的:上层脚本/CI 靠它判断这次到底是成功还是失败。 + // 子进程被信号杀掉时 `code()` 是 `None`(Unix 才有),那种情况退回 2,别假装成功。 + let code = match status { + Ok(status) => status.code().unwrap_or(2), + Err(err) => { + eprintln!("[gpu] 降级重开失败:{err}"); + 2 + } + }; + std::process::exit(code) +} + +/// 按一次后端启动 iced。`backend` 决定这次写进 `ICED_BACKEND` 的唯一一项。 +fn launch( + options: &Options, + families: Families, + flavor: SchemeFlavor, + backend: Kind, +) -> iced::Result { + std::env::set_var("ICED_BACKEND", backend.env_value()); + let boot_options = options.clone(); let application = iced::application( @@ -80,8 +176,26 @@ pub(crate) fn run(options: Options) -> iced::Result { } } -/// 渲染后端探测 + 写 `ICED_BACKEND`。 -fn boot_probe(options: &Options) { +/// 渲染后端探测 + 写 `ICED_BACKEND`,返回判据产物(供 `run` 决定要不要换进程重开)。 +fn boot_probe(options: &Options) -> Decision { + // 本进程是「上一步失败后重开」的那一次:别再探一次(会得到同一个结论,然后无限重开)。 + if let Some(kind) = renderer::forced_fallback() { + std::env::set_var("ICED_BACKEND", kind.env_value()); + let decision = Decision { + primary: kind, + fallback: None, + reason: "fallback-after-failure", + }; + eprintln!( + "[probe] 本次是降级重开({}={}):跳过探测 -> ICED_BACKEND={}", + renderer::FALLBACK_ENV, + kind.env_value(), + kind.env_value() + ); + eprintln!("[probe] {}", decision.describe()); + return decision; + } + let probe_started = Instant::now(); // 只有 `auto` 需要探测:`cpu` 与 `gpu` 的决策与「有没有硬件 GPU」无关, // 而枚举 13 个适配器在本机要 0.65~1.2 s(实测),纯属白花。 @@ -92,7 +206,11 @@ fn boot_probe(options: &Options) { probe::Probe::default() }; let probe_ms = probe_started.elapsed().as_secs_f64() * 1000.0; - probe::apply(options.backend, &probe); + + // `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") { if needs_probe { eprintln!("[probe] 探测耗时 {probe_ms:.0} ms(wgpu 实例 + 全后端适配器枚举)"); @@ -103,6 +221,7 @@ fn boot_probe(options: &Options) { ); } } + decision } /// 默认字体(正文档解析出来的族)。 diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index 14f47bf..3495bf5 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -36,6 +36,7 @@ use unlucky_presenter::router::Route; use unlucky_presenter::theme::CONFIG_POLL_TICKS; use crate::pages; +use crate::renderer::Kind; use super::message::{route_of, Message}; use super::state::{App, IDLE_TICK, TICK, TICK_MS, WATCH_WINDOW}; @@ -326,11 +327,12 @@ impl App { match crate::shot::save(screenshot, &path) { Ok(summary) => { eprintln!("{}", summary.describe()); + // M97:打**实际**后端名(`wgpu`/`tiny-skia`),不是请求值——`renderer=auto` 曾经出现在每张验收图的日志里。 eprintln!( "[shot] screen={} theme={} renderer={} route={:?}", self.options.screen.name(), crate::shot::flavor_slug(&self.ui), - self.options.backend.note(), + super::backend::effective_kind().map_or("?", Kind::name), self.state.route() ); } diff --git a/crates/app-iced/src/lib.rs b/crates/app-iced/src/lib.rs index 342fa77..e26a5c5 100644 --- a/crates/app-iced/src/lib.rs +++ b/crates/app-iced/src/lib.rs @@ -9,6 +9,7 @@ //! | `cli` | `--renderer` / `--software-renderer` / `--theme` / `--screenshot*` 参数 | 业务开关 | //! | `probe` | 渲染后端探测(D27)+ 写 `ICED_BACKEND`,失败自动降级 CPU | 绘制 | //! | `fonts` | 字体栈求值(D28 前半):平台族名 → 系统字体库校验 → 启动日志 | 中文字体下载 | +//! | `renderer` | **渲染后端判据表**(M97)+ wgpu 预检 + `log` 桥:什么条件选 wgpu / 什么时候降级 CPU / 日志该长什么样 | 实际绘帧 | //! | `app` / `shell` | Elm 三件套 + 自绘标题栏 / 导航轨 / 内容骨架 | 业务逻辑 | //! | `shot` | `window::screenshot` → PNG(验收通道) | 图像比对 | //! | `icons` | 令牌里的 SVG path → iced `svg` 控件 | 画新图标 | @@ -27,6 +28,7 @@ pub mod pages; pub mod platform; +pub mod renderer; pub mod shot; pub mod theme; diff --git a/crates/app-iced/src/main.rs b/crates/app-iced/src/main.rs index d6a434a..801e42e 100644 --- a/crates/app-iced/src/main.rs +++ b/crates/app-iced/src/main.rs @@ -27,6 +27,11 @@ fn main() -> ExitCode { return code; } + // M97:把 iced_wgpu / wgpu 的 `log` 输出接到 stderr。`Selected: AdapterInfo{…}` 那行 + // 是「GPU 真生效」的实证——没有它,日志只能证明我们**请求过** GPU。 + // CLI 子命令在上面就返回了;`--diff` 虽然也装,但它不碰 wgpu,不会多出任何一行。 + unlucky_app_iced::renderer::log_bridge::install(); + match run_gui() { Ok(code) => code, Err(message) => { diff --git a/crates/app-iced/src/pages/harness.rs b/crates/app-iced/src/pages/harness.rs index 137a309..e9c8bd8 100644 --- a/crates/app-iced/src/pages/harness.rs +++ b/crates/app-iced/src/pages/harness.rs @@ -30,6 +30,7 @@ use unlucky_design::color::SchemeFlavor; use crate::cli::Backend; use crate::fonts; use crate::probe::{self, Probe}; +use crate::renderer; use crate::shot; use crate::theme::Ui; @@ -195,8 +196,15 @@ fn flavor(raw: &str) -> Result { /// 启动(阻塞到出图或关窗)。 /// +/// ## 取证通道的硬规则(M97) +/// +/// **宁可红着退出,也不能悄悄换一种后端继续出图。** 以前这条路径在 wgpu 挂掉时会静默降级到 +/// tiny-skia,然后照样落一张 PNG,而报告上写的是「GPU 渲染截图」——所有视觉结论的可信度都被 +/// 悄悄打折,输出上看不出来。现在:① 决策被 [`renderer::strict`] 剥掉兜底;② 起不来就带着 +/// **具名**错误返回;③ 每张图的日志里都带 `backend=`,图能自证来源。 +/// /// # Errors -/// 后端探测/字体求值失败、截图落盘失败时返回人话。 +/// 后端探测/字体求值失败、**渲染后端起不来(含 iced 的具名图形错误)**、截图落盘失败时返回人话。 pub fn run(request: Request) -> Result<(), String> { let started = Instant::now(); @@ -206,41 +214,50 @@ pub fn run(request: Request) -> Result<(), String> { } else { Probe::default() }; - probe::apply(request.backend, &probe); + // 剥掉兜底:gallery 没有「偷偷换 CPU」这个选项。 + let decision = renderer::strict(probe::apply(request.backend, &probe)); + eprintln!( + "[shot] 出图后端 = {} reason={}(取证通道无回退:起不来就红着退出,绝不换 CPU 出图)", + decision.primary.name(), + decision.reason + ); let resolved = fonts::resolve(); eprintln!("{}", resolved.describe()); let families = resolved.families(); - let boot = request.clone(); - let application = iced::application( - move || { - ( - Harness { - ui: Ui::new(boot.flavor, families), - model: demo_model(boot.screen, boot.width), - request: boot.clone(), - window: None, - frames: 0, - }, - Task::done(Message::Boot), - ) - }, - Harness::update, - Harness::view, - ) - .theme(Harness::theme) - .subscription(Harness::subscription) - .default_font(iced::Font { - 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() - }); + let application = |backend: renderer::Kind| { + let boot = request.clone(); + iced::application( + move || { + ( + Harness { + ui: Ui::new(boot.flavor, families), + model: demo_model(boot.screen, boot.width), + request: boot.clone(), + window: None, + frames: 0, + backend: backend.name(), + }, + Task::done(Message::Boot), + ) + }, + Harness::update, + Harness::view, + ) + .theme(Harness::theme) + .subscription(Harness::subscription) + .default_font(iced::Font { + 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() + }) + }; eprintln!( "[harness] screen={} width={} height={} slug={}(布局档位 {})", @@ -251,11 +268,23 @@ pub fn run(request: Request) -> Result<(), String> { super::layout::WidthClass::of(request.width).name() ); - match request.scale_factor { - Some(scale) => application.scale_factor(move |_| scale).run(), - None => application.run(), - } - .map_err(|error| format!("iced 启动失败:{error}"))?; + // 兜底已被 `strict` 剥掉,所以这条最多跑一次;具名错误的口径与 App 启动一致, + // 但**取证通道不许降级**:出不了图就红着退出,不换后端(那是另一处 iced 行为)。 + renderer::run_once(decision, |backend| { + std::env::set_var("ICED_BACKEND", backend.env_value()); + let application = application(backend); + match request.scale_factor { + Some(scale) => application.scale_factor(move |_| scale).run(), + None => application.run(), + } + }) + .map_err(|error| match renderer::named_graphics_error(&error) { + Some(named) => format!( + "渲染后端 {} 初始化失败(取证通道无回退,未出图):{named}", + decision.primary.name() + ), + None => format!("iced 启动失败:{error}"), + })?; eprintln!( "[harness] 用时 {:.0} ms", @@ -271,6 +300,8 @@ struct Harness { model: Model, window: Option, frames: u32, + /// 出这张图的后端(`wgpu` / `tiny-skia`)。图要能**自证来源**——以前它证不了(M97)。 + backend: &'static str, } #[derive(Debug, Clone)] @@ -308,7 +339,11 @@ impl Harness { match shot::save(&screenshot, &path) { Ok(summary) => { eprintln!("{}", summary.describe()); - eprintln!("[harness] slug={}", self.request.slug()); + eprintln!( + "[harness] slug={} backend={}(这张图的来源,自证用)", + self.request.slug(), + self.backend + ); } Err(error) => eprintln!("[harness] 截图失败:{error}"), } diff --git a/crates/app-iced/src/probe.rs b/crates/app-iced/src/probe.rs index ca33f08..3b53de0 100644 --- a/crates/app-iced/src/probe.rs +++ b/crates/app-iced/src/probe.rs @@ -8,16 +8,25 @@ //! //! - 本机会枚举出 `Microsoft Basic Render Driver [Cpu]`(= WARP 软光栅), //! 所以「有没有适配器」不能当「有没有 GPU」(M60 实测),必须看 `device_type`。 -//! - 没有硬件 GPU 时要**主动**把 CPU 放到候选首位(`tiny-skia,wgpu`), -//! 否则 wgpu 会先花时间尝试 WARP,观感与行为都难料。 +//! - 没有硬件 GPU 时要**主动**把 CPU 放到候选首位,否则 wgpu 会先花时间尝试 WARP, +//! 观感与行为都难料。 //! -//! ## 语义订正(与 M60 备忘录的差异,以源码 + 实测为准) +//! ## M97 订正:判据在 [`crate::renderer::decide`],日志不再说谎 //! -//! M60 记「`ICED_BACKEND=wgpu` 会**关闭**回退」。读 `iced_renderer-0.14.0/src/fallback.rs` -//! 得到的是:**候选列表**语义——列表里没有 `tiny-skia` 时才真的关闭回退。 -//! 所以本 crate 的「优先 GPU」写 `wgpu,tiny-skia`(首选 GPU,拿不到仍降级,**绝不硬失败**), -//! 而不是 `wgpu`。 +//! 本模块只负责**探测**(`Probe::run`)和**落地**([`apply`]);决策表、兜底、日志格式 +//! 全部搬到了 [`crate::renderer`] 的模块文档里(那张判据表就是验收标准)。 +//! +//! 三条被删掉/改掉的东西,别再加回来: +//! +//! 1. **`ICED_BACKEND` 不再写候选列表**(`wgpu,tiny-skia` / `tiny-skia,wgpu` 这种)。 +//! 列表语义让 iced 自己回退,而它的回退是静默的:`iced_renderer-0.14.0/src/fallback.rs` +//! 把 wgpu 的错误装进 `errors` 向量,tiny-skia 一成功就整体丢弃。现在只写**一项**, +//! 起不来就必然是具名错误,由 `app::boot` 打印并重开降级。 +//! 2. **`auto` + 有硬件不再「不设变量」**。那等于把决策权交给一条我们看不见的路径。 +//! 3. **`renderer = auto (wgpu -> tiny-skia)` 这行删了**。它是按「变量未设」打出来的常量, +//! 跟实际后端无关——M97 的 macOS 报告就是被它误导的。 +use crate::renderer::{self, Decision}; use crate::Backend; /// 单个适配器的摘要(只留日志要用的字段)。 @@ -72,49 +81,37 @@ impl Probe { } } -/// 按请求与探测结果决定 `ICED_BACKEND` 的值(`None` = 不干预,交给 iced 自己回退)。 +/// 把 [`renderer::decide`] 的结论落到进程环境变量,并把**判据**打进日志(不带预检)。 /// -/// 纯函数,好让「降级矩阵」被测试钉死。 -pub fn backend_env(request: Backend, probe: &Probe, gpu_compiled: bool) -> Option<&'static str> { - match (request, gpu_compiled) { - // 命令行 --software-renderer / --renderer=cpu:只留 CPU 候选。 - (Backend::Cpu, _) => Some("tiny-skia"), - // 用户点名要 GPU,但没编进 wgpu:只能 CPU,日志里说明。 - (Backend::Gpu, false) => Some("tiny-skia"), - // 优先 GPU,但保留 tiny-skia 兜底(候选列表语义:不会硬失败)。 - (Backend::Gpu, true) => Some("wgpu,tiny-skia"), - // 自动:有硬件就让 iced 走默认顺序(wgpu 优先,自动回退)。 - (Backend::Auto, true) if probe.hardware => None, - // 自动 + 没编进 wgpu:只有 CPU 候选,别把 wgpu 写进列表(会与「未编入 wgpu」的日志自相矛盾)。 - (Backend::Auto, false) => Some("tiny-skia"), - // 自动 + 有 wgpu 但没硬件(含只有 WARP 的机器):CPU 起步,wgpu 留作兜底。 - (Backend::Auto, true) => Some("tiny-skia,wgpu"), - } +/// 这个入口给「没有重试能力」的调用方(组件库 harness,`--screen=gallery`)用:它写完变量就 +/// 直接 `iced::application(..).run()`,拿不到 `app/boot.rs` 的「换进程降级」。 +pub fn apply(request: Backend, probe: &Probe) -> Decision { + apply_with_preflight(request, probe, None) } -/// 把决策落到进程环境变量,并把探测结果 + 生效值打进日志。 +/// 同上,但接受 [`renderer::preflight`] 的结果(`ULL_PREFLIGHT=1` 时才有值)。 /// -/// 返回**实际生效**的后端描述(`"auto (iced: wgpu -> tiny-skia)"` 之类)。 -pub fn apply(request: Backend, probe: &Probe) -> String { +/// 注意这里是「判据」不是「结果」:日志里不会出现「实际用了哪个后端」这种我们观测不到的话。 +/// 实际后端的取证有两条,都在 [`crate::renderer`] 的判据表里: +/// ① `[gpu] iced_wgpu: …Selected: AdapterInfo…`(iced 自己在创建设备时打的); +/// ② 起不来时的具名错误 + `app/boot.rs` 的重开降级。 +/// +/// 预检失败就把 wgpu 降下来(判据表第 7 行),免得为了拿一条错误去闪一次窗。 +pub fn apply_with_preflight( + request: Backend, + probe: &Probe, + preflight: Option<&Result>, +) -> Decision { let gpu_compiled = cfg!(feature = "gpu"); - let env = backend_env(request, probe, gpu_compiled); + let mut decision = renderer::decide(request, probe, gpu_compiled); + + if matches!(preflight, Some(Err(_))) { + decision = renderer::demote_after_preflight(decision); + } // ICED_BACKEND 是进程级环境变量,必须在 `iced::application(..).run()` 之前写。 // 仓库是 edition 2021 → `set_var` 仍是安全 API(升 2024 后要改成子进程 exec 自身)。 - if let Some(value) = env { - std::env::set_var("ICED_BACKEND", value); - } - - let effective = if gpu_compiled { - match (request, env) { - (Backend::Cpu, _) => "tiny-skia (CPU,强制)".to_owned(), - (_, None) => "auto (wgpu -> tiny-skia)".to_owned(), - (_, Some("wgpu,tiny-skia")) => "wgpu.then(tiny-skia) (优先 GPU)".to_owned(), - (_, Some(value)) => format!("{value} (CPU 起步)"), - } - } else { - "tiny-skia (本构建未编入 wgpu,只有 CPU)".to_owned() - }; + std::env::set_var("ICED_BACKEND", decision.primary.env_value()); let note = if !gpu_compiled && request != Backend::Cpu { ";本构建未编入 wgpu(--no-default-features),只能 CPU" @@ -126,18 +123,24 @@ pub fn apply(request: Backend, probe: &Probe) -> String { "[probe] {} :: request={} -> ICED_BACKEND={}{note}", probe.describe(), request.name(), - env.unwrap_or("") + decision.primary.env_value() ); - eprintln!("[probe] renderer = {effective}"); + eprintln!("[probe] {}", decision.describe()); + if decision.primary == renderer::Kind::Wgpu { + eprintln!( + "[probe] 取证:出现 `[gpu] iced_wgpu: …Selected: AdapterInfo…` ⇒ GPU 真生效;\ + 出现 `[gpu] wgpu 初始化失败` ⇒ 已降级 CPU" + ); + } - effective + decision } #[cfg(feature = "gpu")] fn run_wgpu() -> Probe { // `iced::wgpu` 就是 wgpu crate 本身(`pub use iced_renderer::wgpu::wgpu` 再导出), // 所以不需要单独依赖 wgpu,版本也不会和 iced 打架。 - use iced::wgpu::{Backends, DeviceType, Instance, InstanceDescriptor}; + use iced::wgpu::{Backends, Instance, InstanceDescriptor}; let instance = Instance::new(&InstanceDescriptor { backends: Backends::all(), @@ -149,16 +152,9 @@ fn run_wgpu() -> Probe { .into_iter() .map(|adapter| { let info = adapter.get_info(); - let device_type = match info.device_type { - DeviceType::DiscreteGpu => "DiscreteGpu", - DeviceType::IntegratedGpu => "IntegratedGpu", - DeviceType::VirtualGpu => "VirtualGpu", - DeviceType::Cpu => "Cpu", - DeviceType::Other => "Other", - }; AdapterSummary { name: info.name, - device_type, + device_type: renderer::preflight::device_type_name(info.device_type), } }) .collect::>(); @@ -195,39 +191,12 @@ mod tests { // 本机实测会枚举出 WARP:`Microsoft Basic Render Driver [Cpu]` let warp = probe(&["Cpu"]); assert!(!warp.hardware, "只有 WARP 不能当「有 GPU」"); - assert_eq!( - backend_env(Backend::Auto, &warp, true), - Some("tiny-skia,wgpu") + assert_ne!( + renderer::decide(Backend::Auto, &warp, true).primary, + renderer::Kind::Wgpu ); } - #[test] - fn auto_with_real_gpu_leaves_iced_alone() { - let gpu = probe(&["DiscreteGpu", "Cpu"]); - assert!(gpu.hardware); - assert_eq!(backend_env(Backend::Auto, &gpu, true), None); - } - - #[test] - fn explicit_choices_always_keep_a_fallback() { - let gpu = probe(&["DiscreteGpu"]); - let none = probe(&[]); - // 优先 GPU 但候选列表里留 tiny-skia:拿不到适配器时降级而不是报错 - assert_eq!( - backend_env(Backend::Gpu, &gpu, true), - Some("wgpu,tiny-skia") - ); - assert_eq!( - backend_env(Backend::Gpu, &none, true), - Some("wgpu,tiny-skia") - ); - assert_eq!(backend_env(Backend::Cpu, &gpu, true), Some("tiny-skia")); - // 没编进 wgpu 的构建绝不写 wgpu(连候选列表也不许出现,reviewer P3-4) - assert_eq!(backend_env(Backend::Gpu, &gpu, false), Some("tiny-skia")); - assert_eq!(backend_env(Backend::Auto, &gpu, false), Some("tiny-skia")); - assert_eq!(backend_env(Backend::Auto, &none, false), Some("tiny-skia")); - } - #[test] fn describe_lists_every_adapter() { let text = probe(&["DiscreteGpu", "Cpu"]).describe(); @@ -235,4 +204,23 @@ mod tests { assert!(text.contains("Cpu"), "{text}"); assert!(text.contains("hardware=true"), "{text}"); } + + /// 判据矩阵本身在 [`crate::renderer::decide`] 的测试里逐行钉死(表驱动 + 兜底守门)。 + /// 这里只守 `apply` 这一层的**落地**契约:写进环境变量的永远是**单项**。 + /// + /// 抓得住:谁把候选列表(`wgpu,tiny-skia`)加回来——那会让 iced 的静默回退复活。 + /// 抓不住:iced 运行期真的选了谁(那是判据表第 8 行的事)。 + #[test] + fn apply_writes_exactly_one_backend() { + let gpu = probe(&["IntegratedGpu"]); + for request in [Backend::Auto, Backend::Gpu, Backend::Cpu] { + let decision = renderer::decide(request, &gpu, true); + let value = decision.primary.env_value(); + assert!( + !value.contains(','), + "ICED_BACKEND 只许单项,收到 {value:?}" + ); + assert!(matches!(value, "wgpu" | "tiny-skia"), "{value}"); + } + } } diff --git a/crates/app-iced/src/renderer/decision.rs b/crates/app-iced/src/renderer/decision.rs new file mode 100644 index 0000000..4c74bb3 --- /dev/null +++ b/crates/app-iced/src/renderer/decision.rs @@ -0,0 +1,305 @@ +//! 判据的**纯函数**部分:`请求 × 探测 × 是否编入 wgpu` → 起步后端 + 兜底后端。 +//! +//! 表本身(什么条件选什么、日志该打什么、谁能验证)在 [`super`] 的模块文档顶部。 +//! 这里只放可测的东西:判据一旦掺进运行期副作用(写环境变量、看日志、开窗), +//! 「降级矩阵」就再也写不成表驱动测试了——M97 的教训。 + +use crate::{Backend, Probe}; + +/// 一个具体渲染后端(`ICED_BACKEND` 的单项取值)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + /// `ICED_BACKEND=wgpu`。 + Wgpu, + /// `ICED_BACKEND=tiny-skia`。 + TinySkia, +} + +impl Kind { + /// 写进 `ICED_BACKEND` 的取值(也是 `iced_renderer` 认的名字)。 + #[must_use] + pub fn env_value(self) -> &'static str { + match self { + Kind::Wgpu => "wgpu", + Kind::TinySkia => "tiny-skia", + } + } + + /// 日志里的名字。 + #[must_use] + pub fn name(self) -> &'static str { + self.env_value() + } + + /// 对应的「档位」(D36 的 CPU 档 / GPU 档判据用)。 + #[must_use] + pub fn tier(self) -> Backend { + match self { + Kind::Wgpu => Backend::Gpu, + Kind::TinySkia => Backend::Cpu, + } + } +} + +/// 判据产物:起步后端 + **本 crate 自己**的兜底(不是 iced 的候选回退)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Decision { + /// 写进 `ICED_BACKEND` 的唯一一项。 + pub primary: Kind, + /// 起步失败后重开的后端;`None` = 没有兜底(强制 CPU / 没编 wgpu)。 + pub fallback: Option, + /// 为什么这么判(进日志,验收就靠它)。 + pub reason: &'static str, +} + +impl Decision { + /// 一行给人看的决策描述。 + #[must_use] + pub fn describe(&self) -> String { + match self.fallback { + Some(fallback) => format!( + "renderer = {} reason={} 兜底={}(失败会先打印具名错误)", + self.primary.name(), + self.reason, + fallback.name() + ), + None => format!( + "renderer = {} reason={} 兜底=none(候选表唯一项)", + self.primary.name(), + self.reason + ), + } + } +} + +/// 判据纯函数:请求 × 探测 × 是否编入 wgpu → 决策。 +/// +/// **必须是纯函数**:M97 的教训是判据一旦掺了运行期副作用(写环境变量、看日志), +/// 就再也写不出「降级矩阵」的表驱动测试了。 +#[must_use] +pub fn decide(request: Backend, probe: &Probe, gpu_compiled: bool) -> Decision { + match (request, gpu_compiled) { + // 1. 用户点名 CPU:只有 CPU,且**不给兜底**(强制就是强制)。 + (Backend::Cpu, _) => Decision { + primary: Kind::TinySkia, + fallback: None, + reason: "forced-cpu", + }, + // 2. 点名 GPU 但没编 wgpu:只能 CPU,没得兜。 + (Backend::Gpu, false) => Decision { + primary: Kind::TinySkia, + fallback: None, + reason: "gpu-not-compiled", + }, + // 3. 点名 GPU 且编了:GPU 起步,CPU 兜底(绝不硬失败)。 + (Backend::Gpu, true) => Decision { + primary: Kind::Wgpu, + fallback: Some(Kind::TinySkia), + reason: "forced-gpu", + }, + // 4. auto + 没编 wgpu。 + (Backend::Auto, false) => Decision { + primary: Kind::TinySkia, + fallback: None, + reason: "auto-gpu-not-compiled", + }, + // 5. auto + 只有 WARP/llvmpipe 这类软光栅:CPU 起步,wgpu 留作最后兜底。 + (Backend::Auto, true) if !probe.hardware => Decision { + primary: Kind::TinySkia, + fallback: Some(Kind::Wgpu), + reason: "auto-no-hardware-adapter", + }, + // 6. auto + 有硬件适配器:GPU 起步,CPU 兜底。 + (Backend::Auto, true) => Decision { + primary: Kind::Wgpu, + fallback: Some(Kind::TinySkia), + reason: "auto-hardware-adapter", + }, + } +} + +/// 预检失败时把 wgpu 降下来(判据表第 7 行)。 +/// +/// 只在 `ULL_PREFLIGHT=1` 且预检真的失败时调用:这时候**别去开那个窗**——重试一次要闪两下窗, +/// 而且原因已经拿在手里了。 +#[must_use] +pub fn demote_after_preflight(decision: Decision) -> Decision { + if decision.primary != Kind::Wgpu { + return decision; + } + Decision { + primary: Kind::TinySkia, + fallback: Some(Kind::Wgpu), + reason: "preflight-failed", + } +} + +/// 取证通道(`--screen=gallery`)用:**剥掉兜底**。 +/// +/// 硬规则(M97 定):**取证工具宁可红着退出,也不能悄悄换一种后端继续出图**。 +/// 以前 gallery 在 wgpu 挂掉时会静默出 CPU 截图,而报告上写的是「GPU 渲染截图」—— +/// 所有视觉结论的可信度都被这一条悄悄打折,而且从输出上看不出来。 +#[must_use] +pub fn strict(decision: Decision) -> Decision { + Decision { + fallback: None, + reason: "gallery-strict", + ..decision + } +} +#[cfg(test)] +mod tests { + use super::*; + + fn probe(types: &[&'static str]) -> Probe { + Probe { + adapters: types + .iter() + .map(|device_type| crate::probe::AdapterSummary { + name: "adapter".to_owned(), + device_type, + }) + .collect(), + hardware: types + .iter() + .any(|device_type| !matches!(*device_type, "Cpu" | "Other")), + } + } + + /// 判据表的一行:`请求, 探测结论, 是否编入 wgpu, 期望起步, 期望兜底, 期望 reason`。 + type Case = (Backend, Probe, bool, Kind, Option, &'static str); + + /// 判据表第 1~6 行逐行钉死(表驱动)。谁动了 `decide` 的矩阵,这条先红。 + #[test] + fn criterion_table_rows_1_to_6() { + let hardware = || probe(&["IntegratedGpu"]); + let software = || probe(&["Cpu"]); + let cases: &[Case] = &[ + // 1 强制 CPU:不给兜底 + ( + Backend::Cpu, + hardware(), + true, + Kind::TinySkia, + None, + "forced-cpu", + ), + ( + Backend::Cpu, + software(), + false, + Kind::TinySkia, + None, + "forced-cpu", + ), + // 2 点名 GPU 但没编 + ( + Backend::Gpu, + hardware(), + false, + Kind::TinySkia, + None, + "gpu-not-compiled", + ), + // 3 点名 GPU 且编了 + ( + Backend::Gpu, + hardware(), + true, + Kind::Wgpu, + Some(Kind::TinySkia), + "forced-gpu", + ), + ( + Backend::Gpu, + software(), + true, + Kind::Wgpu, + Some(Kind::TinySkia), + "forced-gpu", + ), + // 4 auto + 没编 + ( + Backend::Auto, + hardware(), + false, + Kind::TinySkia, + None, + "auto-gpu-not-compiled", + ), + // 5 auto + 只有软光栅 + ( + Backend::Auto, + software(), + true, + Kind::TinySkia, + Some(Kind::Wgpu), + "auto-no-hardware-adapter", + ), + // 6 auto + 有硬件(macOS M1 走这条) + ( + Backend::Auto, + hardware(), + true, + Kind::Wgpu, + Some(Kind::TinySkia), + "auto-hardware-adapter", + ), + ]; + + for (request, probe, gpu_compiled, primary, fallback, reason) in cases { + let got = decide(*request, probe, *gpu_compiled); + assert_eq!(got.primary, *primary, "request={request:?} reason={reason}"); + assert_eq!( + got.fallback, *fallback, + "request={request:?} reason={reason}" + ); + assert_eq!(got.reason, *reason); + } + } + + /// **安全网守门**:除了「强制 CPU」和「没编 wgpu」,任何一条判据都必须留兜底。 + /// + /// 抓不住:兜底那次重开本身失败(那是 iced 的事,见判据表第 8 行)。 + #[test] + fn every_decision_keeps_an_exit() { + for request in [Backend::Auto, Backend::Gpu, Backend::Cpu] { + for gpu_compiled in [true, false] { + for types in [&[][..], &["Cpu"][..], &["IntegratedGpu"][..]] { + let decision = decide(request, &probe(types), gpu_compiled); + let forced = request == Backend::Cpu || !gpu_compiled; + assert_eq!( + decision.fallback.is_none(), + forced, + "{decision:?} 必须留兜底(request={request:?} gpu_compiled={gpu_compiled})" + ); + // 兜底不能和起步项相同,否则重试等于原地打转。 + assert_ne!(decision.fallback, Some(decision.primary), "{decision:?}"); + } + } + } + } + + /// 预检失败只降 wgpu 起步的那些行;`--renderer=cpu` 一行都不许动。 + #[test] + fn preflight_demotes_only_wgpu_decisions() { + let hardware = probe(&["IntegratedGpu"]); + let auto = decide(Backend::Auto, &hardware, true); + let demoted = demote_after_preflight(auto); + assert_eq!(demoted.primary, Kind::TinySkia); + assert_eq!(demoted.reason, "preflight-failed"); + assert_eq!(demoted.fallback, Some(Kind::Wgpu), "wgpu 还能当最后兜底"); + + let cpu = decide(Backend::Cpu, &hardware, true); + assert_eq!(demote_after_preflight(cpu), cpu, "强制 CPU 不许被预检改写"); + } + + #[test] + fn describe_names_the_backend_and_the_reason() { + let hardware = probe(&["DiscreteGpu"]); + let text = decide(Backend::Auto, &hardware, true).describe(); + assert!(text.contains("renderer = wgpu"), "{text}"); + assert!(text.contains("reason=auto-hardware-adapter"), "{text}"); + assert!(text.contains("兜底=tiny-skia"), "{text}"); + } +} diff --git a/crates/app-iced/src/renderer/log_bridge.rs b/crates/app-iced/src/renderer/log_bridge.rs new file mode 100644 index 0000000..d45f9a6 --- /dev/null +++ b/crates/app-iced/src/renderer/log_bridge.rs @@ -0,0 +1,151 @@ +//! 把 `iced_wgpu` / `wgpu` 的 `log` 输出转到 stderr(判据表第 8 行的取证通道)。 +//! +//! ## 为什么需要它 +//! +//! `iced_wgpu` 在**真的**创建设备时会自己打一行: +//! +//! ```text +//! Selected: AdapterInfo { name: "Apple M1", backend: Metal, device_type: IntegratedGpu, … } +//! ``` +//! +//! 这行只有「wgpu 走到适配器那一步」才会出现,所以它是**GPU 真生效的实证**——不是我们的推断。 +//! 但它走 `log` crate,而本进程从来没装过 logger,于是 M1 那份报告里这行根本不存在: +//! 「看不到」被当成了「没有」。装一个只放行 GPU 相关 target 的 logger,成本 60 行, +//! 换来的是以后每次验收都能直接看见 wgpu 说了什么。 +//! +//! ## 过滤 +//! +//! 按 **target 分级**,不是一刀切:`wgpu_hal` / `wgpu_core` 在 Info 级别会刷出上百行 Vulkan +//! loader 噪声(M97 实测:启动时 90 行 registry 扫描),把真正有用的 +//! `iced_wgpu: Selected: AdapterInfo{…}` 埋掉。 +//! +//! | `ULL_GPU_LOG` | `iced_wgpu*` | `wgpu*`(hal / core) | 用途 | +//! |---|---|---|---| +//! | 未设 / `info` | Info | **Warn** | 默认:看得见选中的适配器/格式 + wgpu 的告警 | +//! | `warn` | Warn | Warn | 只留告警(启动日志最干净) | +//! | `debug` | Debug | Debug | 全量(诊断用;含 Vulkan loader 噪声) | +//! | `off` | — | — | 不装 logger(回到 M97 之前,仅用于对照实验) | +//! +//! 其它 target(`winit`、`iced_winit` …)一律不放行:它们只会把启动日志淹掉。 + +use std::sync::Once; + +/// 装 logger(进程内只生效一次;重复调用无害)。 +pub fn install() { + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + let Some(level) = level_from_env() else { + return; + }; + // `set_boxed_logger` 只能成功一次;已被别人装过就安静让位,不 panic。 + if log::set_boxed_logger(Box::new(Bridge { level })).is_ok() { + log::set_max_level(level); + } + }); +} + +/// `ULL_GPU_LOG` → 过滤级别;`None` = 别装(`off`)。 +fn level_from_env() -> Option { + parse_level(&std::env::var("ULL_GPU_LOG").unwrap_or_default()) +} + +/// 取值解释(纯函数,测试直接喂字符串,不碰进程环境)。 +fn parse_level(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "off" | "0" | "no" | "false" => None, + "warn" | "warning" => Some(log::LevelFilter::Warn), + "debug" => Some(log::LevelFilter::Debug), + "trace" => Some(log::LevelFilter::Trace), + _ => Some(log::LevelFilter::Info), + } +} + +/// 某个 target 在这个总级别下实际放行到哪一级。 +/// +/// `wgpu_hal`/`wgpu_core` 在 `info` 档被压到 `warn`:它们的 info 是 Vulkan loader 的原文, +/// 对「到底用了哪个后端」没有信息量,只有噪声。 +fn target_level(level: log::LevelFilter, target: &str) -> log::LevelFilter { + if level == log::LevelFilter::Info && !target.starts_with("iced_wgpu") { + return log::LevelFilter::Warn; + } + level +} + +/// 只放行渲染后端相关 target 的 logger。 +struct Bridge { + level: log::LevelFilter, +} + +impl log::Log for Bridge { + fn enabled(&self, metadata: &log::Metadata<'_>) -> bool { + let target = metadata.target(); + is_gpu_target(target) && metadata.level() <= target_level(self.level, target) + } + + fn log(&self, record: &log::Record<'_>) { + if !self.enabled(record.metadata()) { + return; + } + // iced_wgpu 用 `{:#?}` 打多行结构体;压成一行才好 grep / 贴验收报告。 + let message = record.args().to_string().replace('\n', " "); + eprintln!("[gpu] {}: {}", record.target(), message.trim_end()); + } + + fn flush(&self) {} +} + +/// `iced_wgpu::window::compositor` / `wgpu_core::…` / `wgpu_hal::…` 都算。 +fn is_gpu_target(target: &str) -> bool { + ["iced_wgpu", "wgpu"] + .iter() + .any(|prefix| target.starts_with(prefix)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_gpu_targets_pass() { + assert!(is_gpu_target("iced_wgpu::window::compositor")); + assert!(is_gpu_target("wgpu_core::instance")); + assert!(is_gpu_target("wgpu_hal::metal")); + assert!(!is_gpu_target("iced_winit::lib")); + assert!(!is_gpu_target("winit::event_loop")); + } + + #[test] + fn level_env_values_are_interpreted() { + assert_eq!(parse_level(""), Some(log::LevelFilter::Info)); + assert_eq!(parse_level("info"), Some(log::LevelFilter::Info)); + assert_eq!(parse_level(" WARN "), Some(log::LevelFilter::Warn)); + assert_eq!(parse_level("off"), None); + assert_eq!(parse_level("0"), None); + } + + /// 默认档(`info`)下 `wgpu_hal`/`wgpu_core` 被压到 Warn:它们是 Vulkan loader 原文, + /// M97 实测一屏 90 行噪声,会把 `Selected: AdapterInfo` 埋掉。 + #[test] + fn default_level_mutes_wgpu_noise_but_keeps_adapter_line() { + use log::LevelFilter; + assert_eq!( + target_level(LevelFilter::Info, "iced_wgpu::window::compositor"), + LevelFilter::Info, + "选中适配器那行必须留" + ); + assert_eq!( + target_level(LevelFilter::Info, "wgpu_hal::vulkan::instance"), + LevelFilter::Warn, + "Vulkan loader 噪声必须压掉" + ); + assert_eq!( + target_level(LevelFilter::Info, "wgpu_core::instance"), + LevelFilter::Warn + ); + // 显式要 debug 时全放行(诊断用)。 + assert_eq!( + target_level(LevelFilter::Debug, "wgpu_hal::vulkan::instance"), + LevelFilter::Debug + ); + } +} diff --git a/crates/app-iced/src/renderer/mod.rs b/crates/app-iced/src/renderer/mod.rs new file mode 100644 index 0000000..2d4d3da --- /dev/null +++ b/crates/app-iced/src/renderer/mod.rs @@ -0,0 +1,187 @@ +//! 渲染后端的**判据表**(M97)+ 三段可观测性基建。 +//! +//! 本模块回答一个问题:**这个进程到底跑在哪个渲染器上,凭什么这么断定。** +//! +//! ## 判据表(验收直接对照这张表) +//! +//! `ICED_BACKEND` **永远是单项**(不是候选列表)。理由见下节「为什么不再用候选列表」: +//! 单项 ⇒ 「跑起来了 ⇒ 就是它」,不存在需要事后猜的空间。`兜底` 列是**本 crate 自己的重试**, +//! 不是 iced 的回退,所以它一定伴随一行具名错误日志。 +//! +//! | # | 条件 | 选哪个 | 日志该打印什么 | 谁能验证 | +//! |---|---|---|---|---| +//! | 1 | `--renderer=cpu` / `--software-renderer` | `tiny-skia` | `renderer = tiny-skia reason=forced-cpu` | 三平台(CI 只用这条) | +//! | 2 | `--renderer=gpu` 但本构建没编 wgpu | `tiny-skia` | `reason=gpu-not-compiled` + `;本构建未编入 wgpu` | 三平台(`--no-default-features`) | +//! | 3 | `--renderer=gpu` 且编了 wgpu | `wgpu`,兜底 `tiny-skia` | `renderer = wgpu reason=forced-gpu 兜底=tiny-skia` | Windows / Linux 本机;macOS 需真机 | +//! | 4 | `--renderer=auto` 但没编 wgpu | `tiny-skia` | `reason=auto-gpu-not-compiled` | 三平台(`--no-default-features`) | +//! | 5 | `auto` + 枚举不到硬件适配器(只有 WARP / llvmpipe) | `tiny-skia`,兜底 `wgpu` | `reason=auto-no-hardware-adapter` | Windows 本机(软渲染 VM) | +//! | 6 | `auto` + 有硬件适配器 | `wgpu`,兜底 `tiny-skia` | `reason=auto-hardware-adapter` | Windows 本机 **√**;macOS 需真机 | +//! | 7 | 同上但 `ULL_PREFLIGHT=1` 预检失败 | `tiny-skia`,兜底 `wgpu` | `[gpu-preflight] FAILED stage=… error=…` + `reason=preflight-failed` | 三平台(可用 `WGPU_BACKEND=gl` 造) | +//! | 8 | 任何一行选了 wgpu,但 iced 真初始化失败,且**交互启动**(无 `--screenshot`) | 具名错误 → **换一个进程**重开 `tiny-skia` | `[gpu] wgpu 初始化失败(具名):…` → `[gpu] 降级 → tiny-skia(…换一个进程重开…)` → 子进程 `[probe] 本次是降级重开(ULL_GPU_FALLBACK=tiny-skia)` | **Windows 已验**(`WGPU_BACKEND=metal` 造);macOS 需真机 | +//! | 9 | 同上行,但**取证启动**(带 `--screenshot`,含 `--screen=gallery`) | 具名错误 → **不出图**,退出码 1 | `[gpu] wgpu 初始化失败(具名):…` → `[gpu] 拒绝降级出图:本次是取证启动(--screenshot …)` | **Windows 已验**(同造法);macOS 需真机 | +//! +//! **第 8/9 行的硬规则(M97 定)**: +//! +//! - 取证工具(`--screenshot`)宁可红着退出,也不能悄悄换一种后端继续出图。以前 gallery 在 +//! wgpu 挂掉时会静默降级到 tiny-skia 照样落 PNG,而报告上写的是「GPU 渲染截图」——所有视觉 +//! 结论的可信度都被悄悄打折,而且从输出上看不出来。现在每张图的日志里都带 +//! **实际**后端名(`[shot] … renderer=wgpu`),图能自证来源。 +//! - 交互启动保留安全网:真无 GPU / wgpu 起不来时降级到 tiny-skia,不崩。 +//! +//! **降级为什么是换进程**:第一版写的是「打印错误 → 在本进程里再 `run()` 一次」,Windows 实测 +//! 直接 panic(`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)—— +//! winit 不允许一个进程建两次事件循环。所以安全网是 `respawn`:起一个子进程、只换 +//! `ICED_BACKEND` + `ULL_GPU_FALLBACK`,然后带着子进程的退出码退出。子进程看到 +//! `ULL_GPU_FALLBACK` 就跳过探测(否则会得到同一个结论、无限重开)。 +//! +//! `ULL_PREFLIGHT` 开关的代价(**实测**,不是估计):Windows RX 6600 XT / Vulkan 上 +//! `took=717ms`(首次要扫一遍 Vulkan loader),不可用时 `took=0ms`。打开它等于启动多 ~0.7 s, +//! 所以默认关。它测不了 `create_surface`(那时还没有窗口),代替不了真跑一次,只用来「开窗前拿原因」。 +//! 注意:`ULL_PREFLIGHT=1` + 预检失败 + `--screenshot` 时**仍会**降级出图(`reason=preflight-failed`, +//! 图上是 `renderer=tiny-skia`)——那条路径不是静默的,但图确实是 CPU 的,读图前先读日志。 +//! +//! ## 怎么从日志判断「有没有静默降级」 +//! +//! | 你看到 | 结论 | +//! |---|---| +//! | `[probe] renderer = wgpu` 且之后有 `[gpu] iced_wgpu: …Selected: AdapterInfo…` | **真在走 GPU**(这行是 iced_wgpu 自己在创建设备时打的,不是我们的猜测) | +//! | `[gpu] wgpu 初始化失败(具名):…` | wgpu 起不来,**原因就在这行里**;交互启动已换进程降级 CPU,取证启动已拒绝出图 | +//! | `[gpu] 拒绝降级出图:…` | 这张图**没有**产出(取证启动 + wgpu 起不来)。想用 CPU 出图请显式 `--renderer=cpu` | +//! | `[gpu] 降级 → tiny-skia(…换一个进程重开…)` | 交互启动降级了;后面必然跟一行 `[probe] 本次是降级重开` | +//! | `[probe] renderer = tiny-skia reason=…`(`reason` 不是 `forced-cpu`) | 探测判定不该上 GPU,**原因在 `reason`** | +//! | `[shot] … renderer=wgpu` / `renderer=tiny-skia` | 这张图**实际**由哪个后端产出(M97 之前这里打的是请求值 `auto`,等于没打) | +//! | 只有 `[probe] renderer = …` 一行、没有 `[gpu] iced_wgpu:` 也没有取证行 | 那是 M97 之前的旧日志——**它从来没观测过实际后端**,不可采信 | +//! +//! ## 为什么不再用候选列表(M97 的根因订正) +//! +//! M97 之前:`auto` + 有硬件 → **不设** `ICED_BACKEND`,把决策交给 iced 的 +//! `fallback::Compositor`;那条路径里 wgpu 的失败被 `errors` 向量装起来、tiny-skia 一成功就整体丢弃 +//! (`iced_renderer-0.14.0/src/fallback.rs`,我们改不了)。日志只剩 `renderer = auto (wgpu -> tiny-skia)` +//! ——而这行是 `probe.rs` 里按 `env == None` 打出来的**常量字符串**,跟实际后端无关。 +//! 于是 macOS M1 上「枚举到 Apple M1」与「最终是不是真在用 GPU」彻底脱钩:**看起来正常但可能在跑 CPU**。 +//! +//! 现在的唯一判据:`ICED_BACKEND` 只写一项 ⇒ 能跑起来就必定是它;起不来就必然是 +//! `iced::Error::GraphicsCreationFailed` 里那串具名错误(哪个 backend、reason 是什么), +//! 由 `app/boot.rs` 打印后决定「换进程降级」还是「拒绝出图」。 +//! +//! ## 三段基建 +//! +//! | 模块 | 作用 | 默认 | +//! |---|---|---| +//! | [`preflight`] | 在 iced 之前自己跑一遍 wgpu 的「实例→适配器→设备」,把失败原因拿在手里 | **关**(`ULL_PREFLIGHT=1` 打开;会重复创建实例/设备,别放默认路径) | +//! | [`log_bridge`] | 把 `iced_wgpu` / `wgpu` 的 `log` 输出转到 stderr(`Selected: AdapterInfo{…}` 这行就是 GPU 生效的实证) | 开(`ULL_GPU_LOG=off` 可关) | +//! +//! ## 真机验收(macOS Metal 路径 —— **需真机复核**,Windows 验不了这一段) +//! +//! 判据表第 3/6/8/9 行的 macOS 分支只能真机验。下面每段都可直接粘贴;`# 期望` 后面是**必须能在 +//! stderr 里 grep 到的原文**(M98 的验收包会直接把这些字符串编成断言)。 +//! +//! ```bash +//! # 0) 一次 +//! cargo build -p unlucky-app-iced +//! +//! # 1) 交互启动:这才是判「M1 到底有没有在用 GPU」的那次。看完关窗即可。 +//! ./target/debug/unlucky-launcher-iced --renderer=auto +//! # 期望(这两行同一次运行里都要有): +//! # [probe] renderer = wgpu reason=auto-hardware-adapter 兜底=tiny-skia +//! # [gpu] iced_wgpu::window::compositor: Selected: AdapterInfo { name: "Apple M1", +//! # 上面第二行里的 backend 必须是 Metal(同一行靠后)。 +//! # 若出现下面任一行 ⇒ 这次**不是** GPU,把那段原文(含上一行)发回来: +//! # [gpu] wgpu 初始化失败(具名): +//! # [gpu] 降级 → tiny-skia +//! # [probe] 本次是降级重开(ULL_GPU_FALLBACK=tiny-skia) +//! +//! # 2) 开窗前拿原因(不建窗;多花 ~0.7 s) +//! ULL_PREFLIGHT=1 ./target/debug/unlucky-launcher-iced --renderer=auto +//! # 期望:[gpu-preflight] ok adapter="Apple M1" type=IntegratedGpu backend=Metal took= +//! +//! # 3) 取证出图(D30 通道;这一张图自证来源) +//! ./target/debug/unlucky-launcher-iced --screen=gallery --screenshot /tmp/gallery.png +//! # 期望(三行都该有): +//! # [gpu] iced_wgpu::window::compositor: Selected: AdapterInfo { name: "Apple M1", +//! # [shot] 1180x760 scale=1 -> /tmp/gallery.png +//! # [shot] screen=gallery theme=light renderer=wgpu +//! # 若 wgpu 起不来 ⇒ 没有 /tmp/gallery.png、退出码 1、且打印 [gpu] 拒绝降级出图: +//! +//! # 4) 强制 CPU 对照(必须与 M97 之前逐字一致) +//! ./target/debug/unlucky-launcher-iced --renderer=cpu --screenshot /tmp/cpu.png +//! # 期望: +//! # [probe] adapters=0 hardware=false :: request=cpu -> ICED_BACKEND=tiny-skia +//! # [probe] renderer = tiny-skia reason=forced-cpu 兜底=none(候选表唯一项) +//! # [shot] screen=shell theme=light renderer=tiny-skia +//! +//! # 5) 负例:取证启动遇到 wgpu 不可用时必须红着退出(Mac 上用一个不存在的后端来造) +//! WGPU_BACKEND=dx12 ./target/debug/unlucky-launcher-iced --screen=gallery --screenshot /tmp/bad.png +//! # 期望:**没有** /tmp/bad.png、退出码 1、并且打印: +//! # [gpu] wgpu 初始化失败(具名):multiple errors occurred: [GraphicsAdapterNotFound { backend: "wgpu", reason: RequestFailed("no adapter was found for the options requested: +//! # [gpu] 拒绝降级出图:本次是取证启动(--screenshot /tmp/bad.png),换后端出的图会撒谎。 +//! ``` +//! +//! 第 5 段在 Windows 上已用同构的 `WGPU_BACKEND=metal` 验过(`metal support not compiled in` +//! → 具名错误 → 拒绝出图 → exit 1);macOS 上要确认打印的是 `dx12 support not compiled in` 那类 +//! wgpu 原话,而不是我们自己的话。 + +pub mod decision; +pub mod log_bridge; +pub mod preflight; + +pub use decision::{decide, demote_after_preflight, strict, Decision, Kind}; + +/// 把 `iced::Error` 里的**具名**图形错误掏出来(`Display` 那句笼统的话不算原因)。 +/// +/// `iced::Error::GraphicsCreationFailed(inner)` 的 `Display` 只有 +/// 「the application graphics context could not be created」,**没有** `{0}`; +/// 真正的 backend 名与 `Reason` 在内层 `graphics::Error` 里。所以这里同时给 Display 与 Debug。 +#[must_use] +pub fn named_graphics_error(error: &iced::Error) -> Option { + match error { + iced::Error::GraphicsCreationFailed(inner) => Some(format!("{inner}|详情 {inner:?}")), + _ => None, + } +} + +/// 跑一次 iced;起步后端起不来时把**具名**原因交给调用方(不在这里重试)。 +/// +/// ## 为什么不能在本进程里重试(M97 实测) +/// +/// 第一版这里是「打印错误 + 再 `run()` 一次」。Windows 实测直接 panic: +/// +/// ```text +/// thread 'main' panicked at iced_winit-0.14.1/src/lib.rs:81: +/// Create event loop: RecreationAttempt +/// ``` +/// +/// winit 不允许一个进程建两次事件循环,所以**进程内重试在架构上就不成立**。 +/// 安全网因此改成**换一个进程重开**(`app/boot.rs` 的 `respawn`), +/// 猜测的写法别再加回来。 +pub fn run_once(decision: Decision, attempt: impl FnOnce(Kind) -> iced::Result) -> iced::Result { + attempt(decision.primary) +} + +/// 降级重开用的环境变量:子进程看到它就跳过探测、直接用它指向的后端(见 `app::boot::boot_probe`)。 +/// +/// ⚠️ 这是**防无限重开**的唯一闸门:子进程若不跳过探测,会算出同一个结论(auto + 有硬件 → wgpu)、 +/// 再失败、再 respawn,变成自杀式递归。子进程侧的判据在 `boot_probe` 的第一个分支。 +/// +/// 它也是**进程间**的交接约定,不是给用户设的开关(用户入口仍是 `--renderer`)。 +pub const FALLBACK_ENV: &str = "ULL_GPU_FALLBACK"; + +/// 读 [`FALLBACK_ENV`]:本进程是不是「上一步失败后重开」的那一次。 +#[must_use] +pub fn forced_fallback() -> Option { + match std::env::var(FALLBACK_ENV).ok()?.trim() { + "tiny-skia" => Some(Kind::TinySkia), + "wgpu" => Some(Kind::Wgpu), + _ => None, + } +} + +/// 打印**具名**失败原因(三行以内,`stage`/`backend`/`reason` 都可 grep)。 +/// +/// `iced::Error::GraphicsCreationFailed` 的 `Display` **不包含**内部错误 +/// (`#[error("the application graphics context could not be created")]`,没有 `{0}`), +/// 所以这里必须显式把内层错误打出来,否则又变回「只知道失败,不知道为什么」。 +pub fn report_failure(backend: Kind, error: &(impl std::fmt::Display + std::fmt::Debug)) { + eprintln!("[gpu] {} 初始化失败(具名):{error}", backend.name()); + eprintln!("[gpu] 失败详情(具名):{error:?}"); +} diff --git a/crates/app-iced/src/renderer/preflight.rs b/crates/app-iced/src/renderer/preflight.rs new file mode 100644 index 0000000..ff6b101 --- /dev/null +++ b/crates/app-iced/src/renderer/preflight.rs @@ -0,0 +1,244 @@ +//! wgpu 自检(判据表第 7 行):在把控制权交给 iced **之前**自己跑一遍 +//! 「实例 → 适配器 → 设备」,把 wgpu 的具名错误拿在手里。 +//! +//! ## 为什么默认关 +//! +//! 它会**重复** iced 马上要做的活(再建一个实例、再要一个设备),macOS 上一个 Metal 设备 +//! 不是免费的;而且它测不了 `create_surface`(那时还没有窗口),所以它**代替不了**真跑一次。 +//! 它的价值只有一个:让「不上 GPU」这个结论在**开窗之前**就有原因,验收时不用盯着一个闪窗猜。 +//! +//! 开关:`ULL_PREFLIGHT=1`(判据表里登记过,不是隐藏口子)。 +//! +//! ## 它镜像的是谁 +//! +//! 逐字对着 `iced_wgpu-0.14.0/src/window/compositor.rs` 的 `Compositor::request()` 抄的: +//! `Backends::from_env().unwrap_or(all)`、`PowerPreference::from_env().unwrap_or(HighPerformance)`、 +//! `Limits { max_bind_groups: 2, max_non_sampler_bindings: 2048, ..default }`。 +//! 差一处都会让预检和真实路径说不同的话,那时预检就不是证据而是噪声了。 + +/// 预检开关的环境变量名(`1` / `true` 打开)。 +pub const ENV_SWITCH: &str = "ULL_PREFLIGHT"; + +/// 预检成功:拿到的适配器摘要。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Verdict { + /// 适配器名(`Apple M1` / `AMD Radeon RX 6600 XT` / `Microsoft Basic Render Driver`)。 + pub adapter: String, + /// `device_type` 的字符串形式。 + pub device_type: &'static str, + /// 命中哪个 wgpu 后端(`Metal` / `Vulkan` / `Dx12` / `Gl`)。 + pub backend: String, +} + +impl Verdict { + /// 一行日志。 + #[must_use] + pub fn describe(&self) -> String { + format!( + "ok adapter={:?} type={} backend={}", + self.adapter, self.device_type, self.backend + ) + } +} + +/// 预检失败:卡在哪一步 + wgpu 自己的错误文本。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Failure { + /// 失败阶段(日志里 `stage=` 的取值)。 + pub stage: &'static str, + /// wgpu 具名错误(`Display`,原样保留,不翻译成「失败」)。 + pub detail: String, +} + +impl Failure { + /// 一行日志(`stage=` 可直接 grep)。 + #[must_use] + pub fn describe(&self) -> String { + format!("FAILED stage={} error={}", self.stage, self.detail) + } +} + +/// 用户是否要求预检。 +#[must_use] +pub fn requested() -> bool { + std::env::var(ENV_SWITCH).is_ok_and(|value| matches!(value.trim(), "1" | "true" | "yes" | "on")) +} + +/// 按 `ULL_PREFLIGHT` 决定跑不跑;跑了就顺手把结论打进 stderr(带耗时,别让开关成本变成传说)。 +/// +/// 返回 `None` = 没要求预检(调用方按判据表 1~6 行原样走)。 +#[must_use] +pub fn run_if_requested() -> Option> { + if !requested() { + return None; + } + + let started = std::time::Instant::now(); + let verdict = run(); + let took = started.elapsed().as_secs_f64() * 1000.0; + match &verdict { + Ok(ok) => eprintln!("[gpu-preflight] {} took={took:.0}ms", ok.describe()), + Err(failure) => { + eprintln!("[gpu-preflight] {} took={took:.0}ms", failure.describe()); + } + } + Some(verdict) +} + +#[cfg(feature = "gpu")] +/// 真跑一遍 wgpu 初始化(不开窗、不建 surface)。 +/// +/// # Errors +/// 无适配器 / 设备请求被拒时返回 [`Failure`],`detail` 是 wgpu 的原话。 +pub fn run() -> Result { + use iced::wgpu::{ + Backends, ExperimentalFeatures, Features, Instance, InstanceDescriptor, Limits, + MemoryHints, PowerPreference, RequestAdapterOptions, Trace, + }; + + let backends = Backends::from_env().unwrap_or_else(Backends::all); + let instance = Instance::new(&InstanceDescriptor { + backends, + ..Default::default() + }); + + let options = RequestAdapterOptions { + power_preference: PowerPreference::from_env().unwrap_or(PowerPreference::HighPerformance), + // 没有窗口,测不了 surface 兼容性——这一条只能靠真跑一次(判据表第 8 行)。 + compatible_surface: None, + force_fallback_adapter: false, + }; + + let adapter = block_on(instance.request_adapter(&options)).map_err(|error| Failure { + stage: "request-adapter", + detail: format!("{options:?}: {error}"), + })?; + + let info = adapter.get_info(); + let device_type = device_type_name(info.device_type); + + // iced 的 `request_device` 失败时不打日志、只把错误塞进 `RequestDeviceFailed`, + // 所以我们在这里先请求一次,把它的话原样抄下来。 + let _ = block_on(adapter.request_device(&iced::wgpu::DeviceDescriptor { + label: Some("unlucky-launcher gpu preflight"), + required_features: Features::empty(), + required_limits: Limits { + max_bind_groups: 2, + max_non_sampler_bindings: 2048, + ..Limits::default() + }, + memory_hints: MemoryHints::MemoryUsage, + trace: Trace::Off, + experimental_features: ExperimentalFeatures::disabled(), + })) + .map_err(|error| Failure { + stage: "request-device", + detail: error.to_string(), + })?; + + Ok(Verdict { + adapter: info.name, + device_type, + backend: format!("{:?}", info.backend), + }) +} + +#[cfg(not(feature = "gpu"))] +/// 本构建没编 wgpu:预检必然是「不可用」,但这不是错误——判据表第 4 行已经说明原因。 +/// +/// # Errors +/// 恒返回 [`Failure`](`stage=not-compiled`)。 +pub fn run() -> Result { + Err(Failure { + stage: "not-compiled", + detail: "本构建未编入 wgpu(--no-default-features)".to_owned(), + }) +} + +#[cfg(feature = "gpu")] +/// `DeviceType` → 日志里的字符串(探测与预检**共用**这一处,别各写一份 `match`)。 +#[must_use] +pub fn device_type_name(device_type: iced::wgpu::DeviceType) -> &'static str { + use iced::wgpu::DeviceType; + match device_type { + DeviceType::DiscreteGpu => "DiscreteGpu", + DeviceType::IntegratedGpu => "IntegratedGpu", + DeviceType::VirtualGpu => "VirtualGpu", + DeviceType::Cpu => "Cpu", + DeviceType::Other => "Other", + } +} + +/// 把 wgpu 的 future 跑到底。 +/// +/// 只用 `std`:`Waker` 由 `std::task::Wake` 提供(安全 API),运行期负责 `park`。 +/// 不引入 futures 执行器——这里只有一个 future,而且它在 wgpu 的回调线程上唤醒。 +#[cfg(feature = "gpu")] +fn block_on(future: F) -> F::Output { + use std::sync::Arc; + use std::task::{Context, Poll, Wake, Waker}; + + struct Unpark(std::thread::Thread); + + impl Wake for Unpark { + fn wake(self: Arc) { + self.0.unpark(); + } + + fn wake_by_ref(self: &Arc) { + self.0.unpark(); + } + } + + let waker = Waker::from(Arc::new(Unpark(std::thread::current()))); + let mut context = Context::from_waker(&waker); + let mut future = std::pin::pin!(future); + + loop { + match future.as_mut().poll(&mut context) { + Poll::Ready(output) => return output, + Poll::Pending => std::thread::park(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn failure_line_is_greppable() { + let failure = Failure { + stage: "request-device", + detail: "no device request succeeded".to_owned(), + }; + let text = failure.describe(); + assert!( + text.starts_with("FAILED stage=request-device error="), + "{text}" + ); + assert!(text.contains("no device request succeeded"), "{text}"); + } + + #[test] + fn verdict_line_lists_the_three_facts() { + let verdict = Verdict { + adapter: "Apple M1".to_owned(), + device_type: "IntegratedGpu", + backend: "Metal".to_owned(), + }; + let text = verdict.describe(); + assert!(text.contains("adapter=\"Apple M1\""), "{text}"); + assert!(text.contains("type=IntegratedGpu"), "{text}"); + assert!(text.contains("backend=Metal"), "{text}"); + } + + /// 没编 wgpu 的构建:预检必须说清原因,而不是一句「失败」。 + #[cfg(not(feature = "gpu"))] + #[test] + fn unbuilt_gpu_names_the_reason() { + let failure = run().unwrap_err(); + assert_eq!(failure.stage, "not-compiled"); + assert!(failure.detail.contains("wgpu"), "{}", failure.detail); + } +} From fa9dd170d332022c69bc57475709ebb6adc381d4 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 23:58:16 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(renderer):=20M97=20r1=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E9=99=8D=E7=BA=A7/=E5=8F=96=E8=AF=81=E4=B8=A4?= =?UTF-8?q?=E6=9D=A1=E8=B7=AF=E6=94=B9=E6=88=90=E5=8F=AF=E8=A2=AB=E5=8F=98?= =?UTF-8?q?=E5=BC=82=E6=B5=8B=E8=AF=95=E6=8A=93=E5=88=B0=E7=9A=84=E7=BA=AF?= =?UTF-8?q?=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审在 `git archive 72f20d8` 上做了变异:删掉 `respawn` 的 `.env("ICED_BACKEND", fallback.env_value())`,**327 passed / 0 failed**,那条名为 `retry_fallback_flips_the_tier_to_cpu` 的测试照样绿。 名字说它守住了「降级后档位翻转」,实际它只把 `"wgpu"` / `"tiny-skia"` 两个字面量喂给 `backend_from_env` 和 `effective_panel_alpha`,**`respawn` 一行都没执行**(它 `-> !`, 走 `process::exit`)。这和 M97 要修的那行常量日志是同一个结构:**在需要结果的地方验了旁边的东西**。 ## 改了什么 1. `respawn` 的两组 env 抽成纯函数 `fallback_env(fallback)`,返回值是 **`[(&'static str, &'static str); 2]`**——数量由类型钉住(删一条直接编译不过, 比测试更早的闸门),键与值由测试钉住。 2. 子进程那一轮的决策抽成 `renderer::decision::fallback_round(kind)`,**兜底恒为 `None`** (防无限递归重开),`boot_probe` 不再就地拼 `Decision`。 3. 失败分流抽成纯函数 `on_failure(decision, evidence)` → `Respawn(Kind) | Refuse`: 交互降级 / 取证拒绝两条路各自被测试钉住(判据表第 8 行 vs 第 9 行)。 4. 补上复审点名的两处 0 覆盖:`renderer::strict`(剥兜底)与 `renderer::run_once` (传下去的是 `decision.primary`,且进程内只跑一次)。 5. 改名并改文档 `retry_fallback_flips_the_tier_to_cpu` → `each_backend_value_maps_to_its_tier_and_alpha`,明确写「本测试**不**守翻转」, 并指到真正守它的那条。 ## 变异验证(8/8 被抓到,全部在实现行上做,测试里的同一字面量不动) | 变异 | 结果 | |---|---| | M1 删掉 `ICED_BACKEND` 那条(=复审的变异) | **编译期**红:`expected an array with a size of 2, found one with a size of 1` | | M2 键打错 `ICED_BAKEND` | `fallback_env_carries_the_backend_and_the_gate` FAILED | | M3 闸门键打错 `ULL_GPU_FALLBACK_TYPO` | 同上 FAILED | | M4 闸门值写死(不跟 fallback 走) | 同上 FAILED | | M5 `fallback_round` 又给兜底 | `fallback_round_never_degrades_again` FAILED | | M6 取证启动改成也降级 | `evidence_start_refuses_instead_of_degrading` FAILED | | M7 `strict` 忘了清兜底 | `strict_strips_the_fallback` FAILED | | M8 `run_once` 传死值 | `run_once_passes_the_decision_primary` FAILED | 复原后 `cargo test -p unlucky-app-iced --lib` = 334 passed。第一次跑变异时 A/B/C 三个 「照样绿」是我脚本自己的 bug:`sed` 把测试期望里的同一字面量一起改了(自指式变异盲区), 改成只在函数体内替换才暴露真实结果——这条也记进报告。 ## 顺带(p3-1) `boot.rs` 模块文档里「字体栈不重算」是旧实现的残留:改成换进程之后,子进程会把字体栈、 配置、presenter 全部重算(实测首帧 493~531 ms),文档已按实际行为重写,并把判据表 第 8/9 行两条路的差异写进模块头部。 运行时行为未变(refactor 只做抽取),已复测:取证启动 + wgpu 不可用 → exit 1、无 PNG、 具名错误 + 「拒绝降级出图」;交互启动 → 具名错误 + 换进程降级 + `[probe] 本次是降级重开`。 --- crates/app-iced/src/app/backend.rs | 22 ++- crates/app-iced/src/app/boot.rs | 178 ++++++++++++++++++++--- crates/app-iced/src/renderer/decision.rs | 26 ++++ crates/app-iced/src/renderer/mod.rs | 46 +++++- 4 files changed, 243 insertions(+), 29 deletions(-) diff --git a/crates/app-iced/src/app/backend.rs b/crates/app-iced/src/app/backend.rs index cfbcbae..ab22442 100644 --- a/crates/app-iced/src/app/backend.rs +++ b/crates/app-iced/src/app/backend.rs @@ -152,20 +152,26 @@ mod tests { /// 这是修之前做不到的一条:那时变量停在 `wgpu,tiny-skia`,静默回退到 tiny-skia 之后 /// 判据仍然读 `Gpu` ⇒ 半透明在 CPU 渲染器上照开。现在 `app::boot` 重开前会改写这个值, /// 这条测试钉的是「改写成 tiny-skia 之后判据确实翻转」。 + /// **两个后端取值各自对应哪个档位、面板 α 各是多少。** + /// + /// ⚠️ 这条**不**守「降级后档位翻转」——它只是把两个字面量喂给纯函数,`respawn` 一行都没执行。 + /// 复审 M97 r1 p2-1 就是靠这一点把 `respawn` 里的 `.env("ICED_BACKEND", …)` 删掉而测试全绿的。 + /// 真正的翻转由 `app::boot::tests::fallback_env_carries_the_backend_and_the_gate` + /// (respawn 到底传了什么)钉住——那条红了才是「翻转坏了」。 #[test] - fn retry_fallback_flips_the_tier_to_cpu() { + fn each_backend_value_maps_to_its_tier_and_alpha() { use crate::pages::appearance::alpha::effective_panel_alpha; - let before = backend_from_env(Backend::Auto, Some("wgpu")); - assert_eq!(before, Backend::Gpu); - assert_eq!(effective_panel_alpha(before, 0.55), 0.55); + let gpu = backend_from_env(Backend::Auto, Some("wgpu")); + assert_eq!(gpu, Backend::Gpu); + assert_eq!(effective_panel_alpha(gpu, 0.55), 0.55); - let after = backend_from_env(Backend::Auto, Some("tiny-skia")); - assert_eq!(after, Backend::Cpu); + let cpu = backend_from_env(Backend::Auto, Some("tiny-skia")); + assert_eq!(cpu, Backend::Cpu); assert_eq!( - effective_panel_alpha(after, 0.55), + effective_panel_alpha(cpu, 0.55), 1.0, - "D36:真降级到 CPU 后,面板 α 必须强制 1.0" + "D36:CPU 档面板 α 强制 1.0" ); } } diff --git a/crates/app-iced/src/app/boot.rs b/crates/app-iced/src/app/boot.rs index 66491f6..82b93a1 100644 --- a/crates/app-iced/src/app/boot.rs +++ b/crates/app-iced/src/app/boot.rs @@ -1,13 +1,22 @@ //! 启动:后端探测 → 字体求值 → `iced::application(..).run()`。 //! -//! ## M97:`run()` 为什么要重试 +//! ## M97:起步后端起不来时怎么办(两条路,策略不同,别合成一条) //! //! `ICED_BACKEND` 现在永远是**单项**(见 [`crate::renderer`] 的判据表)。单项的代价是 //! iced 不再自己做回退,于是「wgpu 起不来」会变成 `iced::Error::GraphicsCreationFailed` //! ——**这正是我们要的**:那里面装着 wgpu 的具名错误(哪个 backend、卡在 adapter 还是 device)。 -//! 拿到它以后打印、然后换 `tiny-skia` 重开一次,安全网照样在,但降级不再是静默的。 //! -//! 重试只在起步项真的失败时发生,而且**只重试一次**;字体栈不重算(那是启动最贵的一段)。 +//! 拿到具名错误之后分两条路(判据表第 8 行 / 第 9 行): +//! +//! - **交互启动**:换一个**进程**用 `tiny-skia` 重开([`OnFailure::Respawn`])——安全网,用户还能用。 +//! 代价是子进程把启动流程**重跑一遍**(重新探测?不:`ULL_GPU_FALLBACK` 让它跳过探测; +//! 但**字体栈、配置、presenter 都会重算**,实测首帧 531 ms)。这条代价只落在失败路径上。 +//! - **取证启动**(带 `--screenshot`,含 `--screen=gallery`):**拒绝出图**、退出码 1 +//! ([`OnFailure::Refuse`])——换了后端出的图会让报告撒谎。 +//! +//! 为什么必须是「换进程」而不是「在本进程里再 `run()` 一次」:Windows 实测直接 panic +//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)—— +//! winit 一个进程只能建一次事件循环。猜测式重试别再加回来。 use std::time::Instant; @@ -52,19 +61,16 @@ pub(crate) fn run(options: Options) -> iced::Result { } let flavor = options.capture_flavor().unwrap_or(SchemeFlavor::Light); - // 起步后端起不来时**换一个进程**重开(winit 不允许在本进程重建事件循环,见 - // `renderer::run_once` 的文档)。具名原因在换进程之前就打到同一个 stderr 上了。 + // 起步后端起不来时按判据表第 8/9 行分流(交互降级 vs 取证拒绝)。具名原因在分流之前 + // 就打到同一个 stderr 上了。 match launch(&options, families, flavor, decision.primary) { Err(iced::Error::GraphicsCreationFailed(error)) => { renderer::report_failure(decision.primary, &error); - match decision.fallback { - // 交互启动:安全网优先,降级重开,用户还能用。 - Some(fallback) if options.screenshot.is_none() => { - respawn(decision.primary, fallback) - } - // 取证启动(`--screenshot`):**拒绝降级出图**。图是要拿去下结论的, - // 换了后端出的图会撒谎——以前就是这么把 CPU 截图当 GPU 结论用的。 - Some(_) => { + match on_failure(decision, options.screenshot.as_ref()) { + Some(OnFailure::Respawn(fallback)) => respawn(decision.primary, fallback), + Some(OnFailure::Refuse) => { + // 取证启动(`--screenshot`):**拒绝降级出图**。图是要拿去下结论的, + // 换了后端出的图会撒谎——以前就是这么把 CPU 截图当 GPU 结论用的。 eprintln!( "[gpu] 拒绝降级出图:本次是取证启动(--screenshot {}),换后端出的图会撒谎。", options @@ -84,6 +90,45 @@ pub(crate) fn run(options: Options) -> iced::Result { } } +/// 起步后端失败后的去向(判据表第 8 行 vs 第 9 行——**两条路策略不同,别合成一条**)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OnFailure { + /// 交互启动:安全网优先,换进程用这个后端重开。 + Respawn(Kind), + /// 取证启动:红着退出,**不出图**。 + Refuse, +} + +/// 纯判据:起步后端失败时走哪条路。 +/// +/// `evidence` 非空 = 本次是取证启动(带 `--screenshot`,含 `--screen=gallery`),路径只用于日志。 +/// 返回 `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` 后端重开,然后带着它的退出码退出。 /// /// 为什么必须是新进程:winit 一个进程只能建一次事件循环 @@ -112,8 +157,7 @@ fn respawn(failed: Kind, fallback: Kind) -> ! { let status = std::process::Command::new(exe) .args(std::env::args().skip(1)) - .env(renderer::FALLBACK_ENV, fallback.env_value()) - .env("ICED_BACKEND", fallback.env_value()) + .envs(fallback_env(fallback)) .status(); // 退出码**透传**子进程的:上层脚本/CI 靠它判断这次到底是成功还是失败。 @@ -179,13 +223,10 @@ fn launch( /// 渲染后端探测 + 写 `ICED_BACKEND`,返回判据产物(供 `run` 决定要不要换进程重开)。 fn boot_probe(options: &Options) -> Decision { // 本进程是「上一步失败后重开」的那一次:别再探一次(会得到同一个结论,然后无限重开)。 + // 这一步的决策**兜底必须是 None**——由 `decision::fallback_round` 单点保证。 if let Some(kind) = renderer::forced_fallback() { + let decision = renderer::fallback_round(kind); std::env::set_var("ICED_BACKEND", kind.env_value()); - let decision = Decision { - primary: kind, - fallback: None, - reason: "fallback-after-failure", - }; eprintln!( "[probe] 本次是降级重开({}={}):跳过探测 -> ICED_BACKEND={}", renderer::FALLBACK_ENV, @@ -231,3 +272,100 @@ fn default_font(families: Families) -> iced::Font { ..iced::Font::DEFAULT } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::renderer::decision; + + fn probe(device_type: &'static str) -> crate::Probe { + crate::Probe { + adapters: vec![crate::probe::AdapterSummary { + name: "adapter".to_owned(), + device_type, + }], + hardware: !matches!(device_type, "Cpu" | "Other"), + } + } + + /// **降级重开到底传了什么**(M97 r1 p2-1 的守门)。 + /// + /// 抓得住:`fallback_env` 少写/写错任一条——两条都是静默失效型: + /// 漏 `ICED_BACKEND` ⇒ 子进程又去试 wgpu(白开一次窗);漏 `ULL_GPU_FALLBACK` ⇒ **无限重开**。 + /// 抓不住:`Command` 本身没被建起来(那要真起进程,见 §「Windows 实测」)。 + #[test] + fn fallback_env_carries_the_backend_and_the_gate() { + for fallback in [Kind::TinySkia, Kind::Wgpu] { + let env = fallback_env(fallback); + assert_eq!( + env.len(), + 2, + "就这两条,多一条少一条都要在这里改文档:{env:?}" + ); + assert!( + env.contains(&("ICED_BACKEND", fallback.env_value())), + "子进程必须直接用 {fallback:?} 跑,实际 {env:?}" + ); + assert!( + env.contains(&(renderer::FALLBACK_ENV, fallback.env_value())), + "缺防无限重开的闸门 {FALLBACK_ENV:?},实际 {env:?}", + FALLBACK_ENV = renderer::FALLBACK_ENV + ); + } + } + + /// 闸门的另一半:子进程那一轮的决策**不能再有兜底**,否则失败后继续 respawn ⇒ 无限递归。 + /// + /// 抓得住:`fallback_round` 的 `fallback` 被写成 `Some(..)`(自杀式循环)。 + /// 抓不住:`forced_fallback()` 没被 `boot_probe` 调用(那是环境变量的读,见下一条的说明)。 + #[test] + fn fallback_round_never_degrades_again() { + for kind in [Kind::TinySkia, Kind::Wgpu] { + let decision = decision::fallback_round(kind); + assert_eq!(decision.primary, kind); + assert_eq!( + decision.fallback, None, + "降级重开那一次不许再有兜底,否则会无限递归重开" + ); + assert_eq!(decision.reason, "fallback-after-failure"); + } + } + + /// 判据表第 8 行 vs 第 9 行:**交互降级 / 取证拒绝**,两条路的策略不同。 + /// + /// 抓得住:`--screenshot` 分支被改回「也降级」(那正是 M97 要杀的「取证工具自己换后端」)。 + /// 抓不住:`screenshot` 传进来的时机(那是 `run()` 的接线,见 Windows 实测记录)。 + #[test] + fn evidence_start_refuses_instead_of_degrading() { + let auto = decision::decide(crate::Backend::Auto, &probe("IntegratedGpu"), true); + let evidence = std::path::PathBuf::from("out.png"); + + assert_eq!( + on_failure(auto, Some(&evidence)), + Some(OnFailure::Refuse), + "取证启动 + wgpu 起不来 ⇒ 拒绝出图" + ); + assert_eq!( + on_failure(auto, None), + Some(OnFailure::Respawn(Kind::TinySkia)), + "交互启动 + wgpu 起不来 ⇒ 换进程降级" + ); + } + + /// 没有兜底可用的两行(强制 CPU / 未编 wgpu):失败就原样把错误交回去,不许 respawn。 + #[test] + fn no_fallback_means_no_respawn() { + let cpu = decision::decide(crate::Backend::Cpu, &probe("IntegratedGpu"), true); + let evidence = std::path::PathBuf::from("out.png"); + assert_eq!(cpu.fallback, None); + assert_eq!(on_failure(cpu, None), None); + assert_eq!( + on_failure(cpu, Some(&evidence)), + None, + "强制 CPU 也不该走到「拒绝降级」那条提示上(它压根没想上 GPU)" + ); + + let not_compiled = decision::decide(crate::Backend::Auto, &probe("IntegratedGpu"), false); + assert_eq!(on_failure(not_compiled, None), None); + } +} diff --git a/crates/app-iced/src/renderer/decision.rs b/crates/app-iced/src/renderer/decision.rs index 4c74bb3..4e71e52 100644 --- a/crates/app-iced/src/renderer/decision.rs +++ b/crates/app-iced/src/renderer/decision.rs @@ -134,6 +134,20 @@ pub fn demote_after_preflight(decision: Decision) -> Decision { } } +/// 降级重开那一次的决策(判据表第 8 行的**子进程侧**,`boot_probe` 的第一分支用它)。 +/// +/// **`fallback` 必须是 `None`**:这一步的兜底就是「不再兜底」。若它非空,子进程再失败时 +/// 还会 respawn ⇒ **无限递归重开**(每次重开都像「刚刚启动」,这种自杀式循环极难诊断)。 +/// 所以这条不变量只在这里定义一次,并由 `decision::tests` 钉住,别在调用点另拼一个 `Decision`。 +#[must_use] +pub fn fallback_round(kind: Kind) -> Decision { + Decision { + primary: kind, + fallback: None, + reason: "fallback-after-failure", + } +} + /// 取证通道(`--screen=gallery`)用:**剥掉兜底**。 /// /// 硬规则(M97 定):**取证工具宁可红着退出,也不能悄悄换一种后端继续出图**。 @@ -294,6 +308,18 @@ mod tests { assert_eq!(demote_after_preflight(cpu), cpu, "强制 CPU 不许被预检改写"); } + /// 取证通道的决策必须**剥掉兜底**(判据表第 9 行):`strict` 若忘了清 `fallback`, + /// gallery 就能在 wgpu 挂掉时悄悄出 CPU 图——那是 M97 要杀的第九种「假绿」。 + #[test] + fn strict_strips_the_fallback() { + let gpu = decide(Backend::Gpu, &Probe::default(), true); + assert_eq!(gpu.fallback, Some(Kind::TinySkia), "前提:普通决策有兜底"); + let strict = strict(gpu); + assert_eq!(strict.fallback, None, "取证通道不许有兜底"); + assert_eq!(strict.primary, Kind::Wgpu, "剥的是兜底,不是起步项"); + assert_eq!(strict.reason, "gallery-strict"); + } + #[test] fn describe_names_the_backend_and_the_reason() { let hardware = probe(&["DiscreteGpu"]); diff --git a/crates/app-iced/src/renderer/mod.rs b/crates/app-iced/src/renderer/mod.rs index 2d4d3da..aeb5013 100644 --- a/crates/app-iced/src/renderer/mod.rs +++ b/crates/app-iced/src/renderer/mod.rs @@ -125,7 +125,7 @@ pub mod decision; pub mod log_bridge; pub mod preflight; -pub use decision::{decide, demote_after_preflight, strict, Decision, Kind}; +pub use decision::{decide, demote_after_preflight, fallback_round, strict, Decision, Kind}; /// 把 `iced::Error` 里的**具名**图形错误掏出来(`Display` 那句笼统的话不算原因)。 /// @@ -185,3 +185,47 @@ pub fn report_failure(backend: Kind, error: &(impl std::fmt::Display + std::fmt: eprintln!("[gpu] {} 初始化失败(具名):{error}", backend.name()); eprintln!("[gpu] 失败详情(具名):{error:?}"); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Backend, Probe}; + + /// `run_once` **必须**把决策里的起步后端传下去。 + /// + /// 抓得住:`run_once` 被改成传死值(例如恒 `Kind::Wgpu`)——那会让「强制 CPU」也去试 GPU。 + /// 抓不住:真 iced 启动成功/失败(那条只能真跑,见判据表第 6/8/9 行的实测记录)。 + #[test] + fn run_once_passes_the_decision_primary() { + for request in [Backend::Cpu, Backend::Gpu] { + let decision = decide(request, &Probe::default(), true); + let mut seen = None; + let result = run_once(decision, |kind| { + seen = Some(kind); + Ok(()) + }); + assert!(result.is_ok(), "闭包返回 Ok 时 run_once 必须原样返回"); + assert_eq!( + seen, + Some(decision.primary), + "run_once 传给 iced 的后端必须是决策里的起步项" + ); + } + } + + /// `run_once` 只跑一次:失败也**不**在进程内重试(winit 只允许建一次事件循环)。 + /// + /// 抓得住:谁把「进程内重试」加回 `run_once`(会 panic:`RecreationAttempt`)。 + /// 抓不住:`respawn` 那条换进程的路(见 `app::boot::tests`)。 + #[test] + fn run_once_never_retries_in_process() { + let mut calls = 0; + let _ = run_once(decide(Backend::Gpu, &Probe::default(), true), |_| { + calls += 1; + Err(iced::Error::WindowCreationFailed(Box::new( + std::io::Error::other("测试用"), + ))) + }); + assert_eq!(calls, 1, "进程内只许跑一次;重试只能换进程"); + } +} From 53b27eac4555c14f0b2991a30abd145352c284b3 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 3 Nov 2026 00:13:11 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(renderer):=20M97=20r2=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E9=80=80=E5=87=BA=E7=A0=81=E9=80=8F=E4=BC=A0?= =?UTF-8?q?=E6=8B=86=E6=88=90=E5=8F=AF=E6=B5=8B=E5=8D=95=E5=85=83=EF=BC=88?= =?UTF-8?q?=E6=9E=84=E9=80=A0=20/=20=E6=89=A7=E8=A1=8C=EF=BC=89=EF=BC=8C4?= =?UTF-8?q?=20=E4=B8=AA=E5=8F=98=E5=BC=82=E5=85=A8=E6=8A=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一轮把降级重开的 env 钉住了,但 `respawn` 里还剩一段不可测的接线:`status.code()` → `process::exit`。它的失效方式不止「崩溃」,而是**安静地给出一个错结论**——CI 把失败 当成功(或反过来),然后有人基于它做决定。这类和今晚一直在打的东西同类,所以补上。 ## 拆法 - `exit_code(Option) -> i32`:纯函数。`None`(子进程被信号杀死,只有 Unix 会这样) 走兜底码 2,**不 panic、不当成功**。在 Windows 上也能测(测的正是「拿不到码」那一刻)。 - `fallback_command(program, args, fallback) -> Command`:program / args 是参数而不是 在函数里读 `std::env`,唯一目的是让测试能喂假命令(`cmd /c exit 7`)验退出码。 三路 stdio **显式** `inherit()`——`Command` 默认就是继承,但显式写出来,将来有人为了 「安静」加 `.stderr(Stdio::null())` 时 diff 里看得见;降级那次的子进程日志必须和父进程 同一路输出,否则验收脚本抓不到。 - `respawn` 只剩 `current_exe()` + 日志 + `exit_code(status.code())`。 ## 新增测试(4 条,全部变异验证过) | 变异 | 结果 | |---|---| | X1 `unwrap_or(0)`(拿不到码时当成功) | `signal_death_falls_back_to_a_nonzero_code` FAILED | | X2 `Some(_)` 一律 0(不透传) | `fallback_command_really_passes_the_child_exit_code` FAILED(真起 `cmd /c exit 7`,父进程拿到 7) | | X3 重开时丢掉 argv | `fallback_command_passes_program_argv_and_envs` FAILED | | X4 重开时不传 env | 同上 FAILED | 复原后 `cargo test -p unlucky-app-iced --lib` = 337 passed。 **stdio 继承没法用 getter 断言**(`Command` 在 stable 上没有 stdio getter),所以它的证据是 运行时观察:交互降级那次,父进程的 `[gpu] 降级 → …` 与子进程的 `[probe] 本次是降级重开` 落在**同一个 stderr 文件**里(见报告 §运行时复核)。 ## 顺带 - `boot.rs` 超 400 行(492),按仓库既有做法把测试拆到 `src/app/boot/tests.rs` (`#[path = "boot/tests.rs"]`,同 `pages/harness/tests.rs`):boot.rs 311 行、tests.rs 187 行。 - `tests.rs` 模块文档里留了**变异验证的工具纪律**:只在函数体内替换、把测试里的同一字面量 排除在外——全局 `sed` 会连断言一起改掉,于是「实现和期望被换成同一个错值」,测试照样绿, **验证的是变异脚本自己**。M97 r1 第一次跑就这么被骗了三个变异(假绿第十种形态), 这条写给将来改这套测试的人。 --- crates/app-iced/src/app/boot.rs | 146 ++++++-------------- crates/app-iced/src/app/boot/tests.rs | 187 ++++++++++++++++++++++++++ 2 files changed, 230 insertions(+), 103 deletions(-) create mode 100644 crates/app-iced/src/app/boot/tests.rs diff --git a/crates/app-iced/src/app/boot.rs b/crates/app-iced/src/app/boot.rs index 82b93a1..897c7dd 100644 --- a/crates/app-iced/src/app/boot.rs +++ b/crates/app-iced/src/app/boot.rs @@ -146,7 +146,7 @@ fn fallback_env(fallback: Kind) -> [(&'static str, &'static str); 2] { fn respawn(failed: Kind, fallback: Kind) -> ! { let exe = std::env::current_exe().unwrap_or_else(|err| { eprintln!("[gpu] 降级重开失败:拿不到自身路径({err})"); - std::process::exit(2); + std::process::exit(exit_code(None)); }); eprintln!( "[gpu] 降级 → {}({} 已失败,原因见上两行;换一个进程重开:winit 不允许同进程建两次事件循环)。\ @@ -155,23 +155,56 @@ fn respawn(failed: Kind, fallback: Kind) -> ! { failed.name() ); - let status = std::process::Command::new(exe) - .args(std::env::args().skip(1)) - .envs(fallback_env(fallback)) - .status(); + let status = fallback_command(&exe, std::env::args_os().skip(1), fallback).status(); // 退出码**透传**子进程的:上层脚本/CI 靠它判断这次到底是成功还是失败。 - // 子进程被信号杀掉时 `code()` 是 `None`(Unix 才有),那种情况退回 2,别假装成功。 + // 写错这一段的失效方式是「安静地给出一个错结论」——不崩、不静默降级、不无限重开, + // 只是让 CI 把失败当成功。所以它拆成 [`exit_code`] 这个纯函数,由测试钉住。 let code = match status { - Ok(status) => status.code().unwrap_or(2), + Ok(status) => exit_code(status.code()), Err(err) => { eprintln!("[gpu] 降级重开失败:{err}"); - 2 + exit_code(None) } }; std::process::exit(code) } +/// 子进程退出码 → 本进程退出码。 +/// +/// `None` = 子进程被信号杀死(只有 Unix 会这样,`ExitStatus::code` 返回 `None`)。 +/// 这种情况走兜底码而**不是** 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()` 就没法测了。 +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)); + // 三路 stdio **显式**继承。`Command` 默认就是继承,但显式写出来有两个作用: + // ① 将来为了「安静」加上 `.stderr(Stdio::null())` 时,diff 里看得见; + // ② 降级那次的子进程日志(`[probe] 本次是降级重开`)**必须和父进程同一路输出**, + // 否则验收脚本只抓到父进程那两行具名错误,看不到降级后到底用什么跑起来了。 + // 注:`Command` 没有 stdio 的 getter(stable 没有),所以这条**只能靠真跑一次**证明 + // (见 §Windows 实测的 `interactive.log`:父子两段日志在同一个 stderr 文件里)。 + 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, @@ -274,98 +307,5 @@ fn default_font(families: Families) -> iced::Font { } #[cfg(test)] -mod tests { - use super::*; - use crate::renderer::decision; - - fn probe(device_type: &'static str) -> crate::Probe { - crate::Probe { - adapters: vec![crate::probe::AdapterSummary { - name: "adapter".to_owned(), - device_type, - }], - hardware: !matches!(device_type, "Cpu" | "Other"), - } - } - - /// **降级重开到底传了什么**(M97 r1 p2-1 的守门)。 - /// - /// 抓得住:`fallback_env` 少写/写错任一条——两条都是静默失效型: - /// 漏 `ICED_BACKEND` ⇒ 子进程又去试 wgpu(白开一次窗);漏 `ULL_GPU_FALLBACK` ⇒ **无限重开**。 - /// 抓不住:`Command` 本身没被建起来(那要真起进程,见 §「Windows 实测」)。 - #[test] - fn fallback_env_carries_the_backend_and_the_gate() { - for fallback in [Kind::TinySkia, Kind::Wgpu] { - let env = fallback_env(fallback); - assert_eq!( - env.len(), - 2, - "就这两条,多一条少一条都要在这里改文档:{env:?}" - ); - assert!( - env.contains(&("ICED_BACKEND", fallback.env_value())), - "子进程必须直接用 {fallback:?} 跑,实际 {env:?}" - ); - assert!( - env.contains(&(renderer::FALLBACK_ENV, fallback.env_value())), - "缺防无限重开的闸门 {FALLBACK_ENV:?},实际 {env:?}", - FALLBACK_ENV = renderer::FALLBACK_ENV - ); - } - } - - /// 闸门的另一半:子进程那一轮的决策**不能再有兜底**,否则失败后继续 respawn ⇒ 无限递归。 - /// - /// 抓得住:`fallback_round` 的 `fallback` 被写成 `Some(..)`(自杀式循环)。 - /// 抓不住:`forced_fallback()` 没被 `boot_probe` 调用(那是环境变量的读,见下一条的说明)。 - #[test] - fn fallback_round_never_degrades_again() { - for kind in [Kind::TinySkia, Kind::Wgpu] { - let decision = decision::fallback_round(kind); - assert_eq!(decision.primary, kind); - assert_eq!( - decision.fallback, None, - "降级重开那一次不许再有兜底,否则会无限递归重开" - ); - assert_eq!(decision.reason, "fallback-after-failure"); - } - } - - /// 判据表第 8 行 vs 第 9 行:**交互降级 / 取证拒绝**,两条路的策略不同。 - /// - /// 抓得住:`--screenshot` 分支被改回「也降级」(那正是 M97 要杀的「取证工具自己换后端」)。 - /// 抓不住:`screenshot` 传进来的时机(那是 `run()` 的接线,见 Windows 实测记录)。 - #[test] - fn evidence_start_refuses_instead_of_degrading() { - let auto = decision::decide(crate::Backend::Auto, &probe("IntegratedGpu"), true); - let evidence = std::path::PathBuf::from("out.png"); - - assert_eq!( - on_failure(auto, Some(&evidence)), - Some(OnFailure::Refuse), - "取证启动 + wgpu 起不来 ⇒ 拒绝出图" - ); - assert_eq!( - on_failure(auto, None), - Some(OnFailure::Respawn(Kind::TinySkia)), - "交互启动 + wgpu 起不来 ⇒ 换进程降级" - ); - } - - /// 没有兜底可用的两行(强制 CPU / 未编 wgpu):失败就原样把错误交回去,不许 respawn。 - #[test] - fn no_fallback_means_no_respawn() { - let cpu = decision::decide(crate::Backend::Cpu, &probe("IntegratedGpu"), true); - let evidence = std::path::PathBuf::from("out.png"); - assert_eq!(cpu.fallback, None); - assert_eq!(on_failure(cpu, None), None); - assert_eq!( - on_failure(cpu, Some(&evidence)), - None, - "强制 CPU 也不该走到「拒绝降级」那条提示上(它压根没想上 GPU)" - ); - - let not_compiled = decision::decide(crate::Backend::Auto, &probe("IntegratedGpu"), false); - assert_eq!(on_failure(not_compiled, None), None); - } -} +#[path = "boot/tests.rs"] +mod tests; diff --git a/crates/app-iced/src/app/boot/tests.rs b/crates/app-iced/src/app/boot/tests.rs new file mode 100644 index 0000000..3765fd0 --- /dev/null +++ b/crates/app-iced/src/app/boot/tests.rs @@ -0,0 +1,187 @@ +//! `app/boot.rs` 的单元测试 + **变异验证说明**(M97 r1)。 +//! +//! 放独立文件(同 `pages/harness/tests.rs`)是为了让 `boot.rs` 留在 400 行内。 + +//! ## 变异验证怎么做(M97 r1 的教训,改测试前先读这段) +//! +//! 这批测试的价值不是「跑绿了」,而是「把实现改坏会红」。做变异时**只在目标函数体内替换**, +//! 并且把测试里的同一字面量排除在外: +//! +//! ```text +//! # // 示意(真实脚本是一次性的,没入库) +//! def in_body(path, marker, changes): +//! """按函数体范围替换。**不能**全局 sed:那会连测试期望里的同一字面量一起改掉, +//! 于是实现和断言被换成同一个错值 ⇒ 测试照样绿 ⇒ 验证的是变异脚本自己。""" +//! ``` +//! +//! M97 r1 第一次跑变异时,M1/M2/M3 三个变异「照样绿」——不是测试弱,是脚本用全局 `sed` +//! 把断言一起改了。这类假绿最阴:变异验证本来是我们判断「测试够不够」的终极手段, +//! 手段本身假绿时,它还会附赠「已验证」的强信心。 + +use super::*; + +use crate::renderer::decision; +use std::ffi::{OsStr, OsString}; +use std::path::Path; + +fn probe(device_type: &'static str) -> crate::Probe { + crate::Probe { + adapters: vec![crate::probe::AdapterSummary { + name: "adapter".to_owned(), + device_type, + }], + hardware: !matches!(device_type, "Cpu" | "Other"), + } +} + +/// **降级重开到底传了什么**(M97 r1 p2-1 的守门)。 +/// +/// 抓得住:`fallback_env` 少写/写错任一条——两条都是静默失效型: +/// 漏 `ICED_BACKEND` ⇒ 子进程又去试 wgpu(白开一次窗);漏 `ULL_GPU_FALLBACK` ⇒ **无限重开**。 +/// 抓不住:`Command` 本身没被建起来(那要真起进程,见 §「Windows 实测」)。 +#[test] +fn fallback_env_carries_the_backend_and_the_gate() { + for fallback in [Kind::TinySkia, Kind::Wgpu] { + let env = fallback_env(fallback); + assert_eq!( + env.len(), + 2, + "就这两条,多一条少一条都要在这里改文档:{env:?}" + ); + assert!( + env.contains(&("ICED_BACKEND", fallback.env_value())), + "子进程必须直接用 {fallback:?} 跑,实际 {env:?}" + ); + assert!( + env.contains(&(renderer::FALLBACK_ENV, fallback.env_value())), + "缺防无限重开的闸门 {FALLBACK_ENV:?},实际 {env:?}", + FALLBACK_ENV = renderer::FALLBACK_ENV + ); + } +} + +/// 闸门的另一半:子进程那一轮的决策**不能再有兜底**,否则失败后继续 respawn ⇒ 无限递归。 +/// +/// 抓得住:`fallback_round` 的 `fallback` 被写成 `Some(..)`(自杀式循环)。 +/// 抓不住:`forced_fallback()` 没被 `boot_probe` 调用(那是环境变量的读,见下一条的说明)。 +#[test] +fn fallback_round_never_degrades_again() { + for kind in [Kind::TinySkia, Kind::Wgpu] { + let decision = decision::fallback_round(kind); + assert_eq!(decision.primary, kind); + assert_eq!( + decision.fallback, None, + "降级重开那一次不许再有兜底,否则会无限递归重开" + ); + assert_eq!(decision.reason, "fallback-after-failure"); + } +} + +/// 判据表第 8 行 vs 第 9 行:**交互降级 / 取证拒绝**,两条路的策略不同。 +/// +/// 抓得住:`--screenshot` 分支被改回「也降级」(那正是 M97 要杀的「取证工具自己换后端」)。 +/// 抓不住:`screenshot` 传进来的时机(那是 `run()` 的接线,见 Windows 实测记录)。 +#[test] +fn evidence_start_refuses_instead_of_degrading() { + let auto = decision::decide(crate::Backend::Auto, &probe("IntegratedGpu"), true); + let evidence = std::path::PathBuf::from("out.png"); + + assert_eq!( + on_failure(auto, Some(&evidence)), + Some(OnFailure::Refuse), + "取证启动 + wgpu 起不来 ⇒ 拒绝出图" + ); + assert_eq!( + on_failure(auto, None), + Some(OnFailure::Respawn(Kind::TinySkia)), + "交互启动 + wgpu 起不来 ⇒ 换进程降级" + ); +} + +/// **构造侧**:argv 原样透传、两组 env 都在、program 就是传进来的那个。 +/// +/// 抓得住:重开时丢掉用户参数(`--screenshot` / `--screen` / `--data-dir` 全没了, +/// 子进程会按默认参数跑,那是最难查的一种「重开后行为变了」)、env 少传(M97 r1 的 p2-1)。 +/// 抓不住:stdio 继承(`Command` 没有 stdio getter,只能真跑一次,见 `fallback_command` 的注释)。 +#[test] +fn fallback_command_passes_program_argv_and_envs() { + let args = ["--renderer=auto", "--screenshot", "out.png"]; + let command = fallback_command( + Path::new("unlucky-launcher-iced"), + args.iter().map(OsString::from), + Kind::TinySkia, + ); + + assert_eq!( + command.get_program(), + OsStr::new("unlucky-launcher-iced"), + "program 必须是传进来的那个(生产里是 current_exe)" + ); + assert_eq!( + command.get_args().collect::>(), + args.iter().map(OsStr::new).collect::>(), + "argv 必须原样透传,一条不漏、顺序不变" + ); + let envs: Vec<_> = command.get_envs().collect(); + for (key, value) in fallback_env(Kind::TinySkia) { + assert!( + envs.contains(&(OsStr::new(key), Some(OsStr::new(value)))), + "重开命令缺 {key}={value},实际 {envs:?}" + ); + } +} + +/// **执行侧**:子进程退出 7 → 父进程拿到 7(不是 0、不是自己造的值)。 +/// +/// 抓得住:`exit_code` 被改成恒 0 / 恒 1 / 写成 `status.success().into()`。 +/// 抓不住:生产里 `current_exe()` 那个 program(测试用假命令,见 `fallback_command` 注释)。 +#[test] +fn fallback_command_really_passes_the_child_exit_code() { + #[cfg(windows)] + let (program, args) = ("cmd", vec!["/c", "exit", "7"]); + #[cfg(unix)] + let (program, args) = ("sh", vec!["-c", "exit 7"]); + + let status = fallback_command( + Path::new(program), + args.iter().map(OsString::from), + Kind::TinySkia, + ) + .status() + .expect("假子进程必须能起来"); + assert_eq!( + exit_code(status.code()), + 7, + "子进程退出 7,父进程必须透传 7;{status:?}" + ); +} + +/// 子进程**被信号杀死**(`code()` 为 `None`):走兜底码,不 panic、不当成功。 +/// +/// 这是纯函数测试,所以在 Windows 上也能跑(真造一个信号死亡的 `ExitStatus` +/// 需要 `ExitStatusExt`,只有 Unix 有;而这里测的正是「拿不到码」那一刻的行为)。 +#[test] +fn signal_death_falls_back_to_a_nonzero_code() { + assert_eq!(exit_code(Some(0)), 0, "成功必须透传 0"); + assert_eq!(exit_code(Some(7)), 7); + let fallback = exit_code(None); + assert_ne!(fallback, 0, "拿不到退出码绝不能当成功"); + assert_eq!(fallback, 2, "兜底码与 main 的「参数或运行错误」一致"); +} + +/// 没有兜底可用的两行(强制 CPU / 未编 wgpu):失败就原样把错误交回去,不许 respawn。 +#[test] +fn no_fallback_means_no_respawn() { + let cpu = decision::decide(crate::Backend::Cpu, &probe("IntegratedGpu"), true); + let evidence = std::path::PathBuf::from("out.png"); + assert_eq!(cpu.fallback, None); + assert_eq!(on_failure(cpu, None), None); + assert_eq!( + on_failure(cpu, Some(&evidence)), + None, + "强制 CPU 也不该走到「拒绝降级」那条提示上(它压根没想上 GPU)" + ); + + let not_compiled = decision::decide(crate::Backend::Auto, &probe("IntegratedGpu"), false); + assert_eq!(on_failure(not_compiled, None), None); +}