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` 的「真机验收」一节。
306 lines
11 KiB
Rust
306 lines
11 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",
|
||
}
|
||
}
|
||
|
||
/// 取证通道(`--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 不许被预检改写");
|
||
}
|
||
|
||
#[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}");
|
||
}
|
||
}
|