From fa9dd170d332022c69bc57475709ebb6adc381d4 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 23:58:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(renderer):=20M97=20r1=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20=E9=99=8D=E7=BA=A7/=E5=8F=96=E8=AF=81=E4=B8=A4=E6=9D=A1?= =?UTF-8?q?=E8=B7=AF=E6=94=B9=E6=88=90=E5=8F=AF=E8=A2=AB=E5=8F=98=E5=BC=82?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=8A=93=E5=88=B0=E7=9A=84=E7=BA=AF=E5=87=BD?= =?UTF-8?q?=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, "进程内只许跑一次;重试只能换进程"); + } +}