复审在 `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] 本次是降级重开`。
332 lines
12 KiB
Rust
332 lines
12 KiB
Rust
//! 判据的**纯函数**部分:`请求 × 探测 × 是否编入 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<Kind>,
|
||
/// 为什么这么判(进日志,验收就靠它)。
|
||
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",
|
||
}
|
||
}
|
||
|
||
/// 降级重开那一次的决策(判据表第 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 定):**取证工具宁可红着退出,也不能悄悄换一种后端继续出图**。
|
||
/// 以前 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<Kind>, &'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 不许被预检改写");
|
||
}
|
||
|
||
/// 取证通道的决策必须**剥掉兜底**(判据表第 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"]);
|
||
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}");
|
||
}
|
||
}
|