fix(renderer): M97 r1 —— 降级/取证两条路改成可被变异测试抓到的纯函数

复审在 `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] 本次是降级重开`。
This commit is contained in:
AskaEth committed 2026-11-03 00:26:56 +08:00
1 parent 31cdfd5bbd
commit fa9dd170d3
4 files changed
+243 -29

No files matched your search

+14 -8
View File
@@ -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"
);
}
}
+158 -20
View File
@@ -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<OnFailure> {
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);
}
}
+26
View File
@@ -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"]);
+45 -1
View File
@@ -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, "进程内只许跑一次;重试只能换进程");
}
}