fix(renderer): M97 后端判据 —— 静默降级改具名错误,取证通道拒绝降级出图

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` 的「真机验收」一节。
This commit is contained in:
AskaEth committed 2026-11-03 00:26:55 +08:00
1 parent 45cd44f756
commit 31cdfd5bbd
13 files changed
+1260 -157

No files matched your search

+72 -37
View File
@@ -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<SchemeFlavor, String> {
/// 启动(阻塞到出图或关窗)。
///
/// ## 取证通道的硬规则(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<window::Id>,
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}"),
}