上一轮把降级重开的 env 钉住了,但 `respawn` 里还剩一段不可测的接线:`status.code()` → `process::exit`。它的失效方式不止「崩溃」,而是**安静地给出一个错结论**——CI 把失败 当成功(或反过来),然后有人基于它做决定。这类和今晚一直在打的东西同类,所以补上。 ## 拆法 - `exit_code(Option<i32>) -> i32`:纯函数。`None`(子进程被信号杀死,只有 Unix 会这样) 走兜底码 2,**不 panic、不当成功**。在 Windows 上也能测(测的正是「拿不到码」那一刻)。 - `fallback_command(program, args, fallback) -> Command`:program / args 是参数而不是 在函数里读 `std::env`,唯一目的是让测试能喂假命令(`cmd /c exit 7`)验退出码。 三路 stdio **显式** `inherit()`——`Command` 默认就是继承,但显式写出来,将来有人为了 「安静」加 `.stderr(Stdio::null())` 时 diff 里看得见;降级那次的子进程日志必须和父进程 同一路输出,否则验收脚本抓不到。 - `respawn` 只剩 `current_exe()` + 日志 + `exit_code(status.code())`。 ## 新增测试(4 条,全部变异验证过) | 变异 | 结果 | |---|---| | X1 `unwrap_or(0)`(拿不到码时当成功) | `signal_death_falls_back_to_a_nonzero_code` FAILED | | X2 `Some(_)` 一律 0(不透传) | `fallback_command_really_passes_the_child_exit_code` FAILED(真起 `cmd /c exit 7`,父进程拿到 7) | | X3 重开时丢掉 argv | `fallback_command_passes_program_argv_and_envs` FAILED | | X4 重开时不传 env | 同上 FAILED | 复原后 `cargo test -p unlucky-app-iced --lib` = 337 passed。 **stdio 继承没法用 getter 断言**(`Command` 在 stable 上没有 stdio getter),所以它的证据是 运行时观察:交互降级那次,父进程的 `[gpu] 降级 → …` 与子进程的 `[probe] 本次是降级重开` 落在**同一个 stderr 文件**里(见报告 §运行时复核)。 ## 顺带 - `boot.rs` 超 400 行(492),按仓库既有做法把测试拆到 `src/app/boot/tests.rs` (`#[path = "boot/tests.rs"]`,同 `pages/harness/tests.rs`):boot.rs 311 行、tests.rs 187 行。 - `tests.rs` 模块文档里留了**变异验证的工具纪律**:只在函数体内替换、把测试里的同一字面量 排除在外——全局 `sed` 会连断言一起改掉,于是「实现和期望被换成同一个错值」,测试照样绿, **验证的是变异脚本自己**。M97 r1 第一次跑就这么被骗了三个变异(假绿第十种形态), 这条写给将来改这套测试的人。
312 lines
14 KiB
Rust
312 lines
14 KiB
Rust
//! 启动:后端探测 → 字体求值 → `iced::application(..).run()`。
|
||
//!
|
||
//! ## M97:起步后端起不来时怎么办(两条路,策略不同,别合成一条)
|
||
//!
|
||
//! `ICED_BACKEND` 现在永远是**单项**(见 [`crate::renderer`] 的判据表)。单项的代价是
|
||
//! iced 不再自己做回退,于是「wgpu 起不来」会变成 `iced::Error::GraphicsCreationFailed`
|
||
//! ——**这正是我们要的**:那里面装着 wgpu 的具名错误(哪个 backend、卡在 adapter 还是 device)。
|
||
//!
|
||
//! 拿到具名错误之后分两条路(判据表第 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;
|
||
|
||
use iced::{window, Task};
|
||
|
||
use unlucky_design::color::SchemeFlavor;
|
||
|
||
use crate::cli::Options;
|
||
use crate::fonts;
|
||
use crate::probe;
|
||
use crate::renderer::{self, Decision, Kind};
|
||
use crate::theme::Families;
|
||
|
||
use super::message::Message;
|
||
use super::state::{App, STARTED};
|
||
use super::{MIN_SIZE, WINDOW_SIZE};
|
||
|
||
/// 启动 iced(阻塞到退出)。`--diff` 不走这里,见 `main.rs`。
|
||
pub(crate) fn run(options: Options) -> iced::Result {
|
||
let _ = STARTED.set(Instant::now());
|
||
|
||
let decision = boot_probe(&options);
|
||
|
||
let font_started = Instant::now();
|
||
let resolved = fonts::resolve();
|
||
eprintln!(
|
||
"[font] 求值耗时 {:.0} ms",
|
||
font_started.elapsed().as_secs_f64() * 1000.0
|
||
);
|
||
eprintln!("{}", resolved.describe());
|
||
if resolved.has_synthetic() {
|
||
eprintln!(
|
||
"[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换族)要处理"
|
||
);
|
||
}
|
||
let families = resolved.families();
|
||
|
||
// 截图模式:`--screenshot-theme` > `--theme` > **Light**(唯一取值口在 `Options::capture_flavor`)。
|
||
// 这里**不再有**「等系统主题到达」的闸门——CI 的 xvfb 没有主题会话,等它就是挂死(见 cli 文档)。
|
||
if options.screenshot.is_some() && options.shot_flavor().is_none() {
|
||
eprintln!("[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)");
|
||
}
|
||
let flavor = options.capture_flavor().unwrap_or(SchemeFlavor::Light);
|
||
|
||
// 起步后端起不来时按判据表第 8/9 行分流(交互降级 vs 取证拒绝)。具名原因在分流之前
|
||
// 就打到同一个 stderr 上了。
|
||
match launch(&options, families, flavor, decision.primary) {
|
||
Err(iced::Error::GraphicsCreationFailed(error)) => {
|
||
renderer::report_failure(decision.primary, &error);
|
||
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
|
||
.screenshot
|
||
.as_ref()
|
||
.map_or_else(String::new, |path| path.display().to_string())
|
||
);
|
||
eprintln!(
|
||
"[gpu] 想用 CPU 出图请显式加 --renderer=cpu / --software-renderer(CI 就是这么跑的)。"
|
||
);
|
||
Err(iced::Error::GraphicsCreationFailed(error))
|
||
}
|
||
None => Err(iced::Error::GraphicsCreationFailed(error)),
|
||
}
|
||
}
|
||
other => other,
|
||
}
|
||
}
|
||
|
||
/// 起步后端失败后的去向(判据表第 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 一个进程只能建一次事件循环
|
||
/// (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)。
|
||
///
|
||
/// 只在**交互启动**时走到这里;取证启动(`--screenshot`)宁可红着退出也不换后端出图
|
||
/// (判据表第 8 行 vs 第 9 行——**这两条路的策略不同,别合成一条**)。
|
||
///
|
||
/// ## `ULL_GPU_FALLBACK` 是防自杀循环的唯一闸门
|
||
///
|
||
/// 子进程看到 [`renderer::FALLBACK_ENV`] **必须跳过探测**(`boot_probe` 的第一段),换成
|
||
/// `tiny-skia` 直接跑;否则子进程会算出和父进程一样的结论(auto + 有硬件 → wgpu)、再失败、
|
||
/// 再 respawn —— **无限递归重开**。这种循环极难复现也极难诊断(每次重开都像是「刚刚启动」),
|
||
/// 所以闸门放在探测入口的第一个分支,不放在 respawn 那一侧。
|
||
fn respawn(failed: Kind, fallback: Kind) -> ! {
|
||
let exe = std::env::current_exe().unwrap_or_else(|err| {
|
||
eprintln!("[gpu] 降级重开失败:拿不到自身路径({err})");
|
||
std::process::exit(exit_code(None));
|
||
});
|
||
eprintln!(
|
||
"[gpu] 降级 → {}({} 已失败,原因见上两行;换一个进程重开:winit 不允许同进程建两次事件循环)。\
|
||
下一次的日志从 `[probe] 本次是降级重开` 开始。",
|
||
fallback.name(),
|
||
failed.name()
|
||
);
|
||
|
||
let status = fallback_command(&exe, std::env::args_os().skip(1), fallback).status();
|
||
|
||
// 退出码**透传**子进程的:上层脚本/CI 靠它判断这次到底是成功还是失败。
|
||
// 写错这一段的失效方式是「安静地给出一个错结论」——不崩、不静默降级、不无限重开,
|
||
// 只是让 CI 把失败当成功。所以它拆成 [`exit_code`] 这个纯函数,由测试钉住。
|
||
let code = match status {
|
||
Ok(status) => exit_code(status.code()),
|
||
Err(err) => {
|
||
eprintln!("[gpu] 降级重开失败:{err}");
|
||
exit_code(None)
|
||
}
|
||
};
|
||
std::process::exit(code)
|
||
}
|
||
|
||
/// 子进程退出码 → 本进程退出码。
|
||
///
|
||
/// `None` = 子进程被信号杀死(只有 Unix 会这样,`ExitStatus::code` 返回 `None`)。
|
||
/// 这种情况走兜底码而**不是** panic,也不是 0:上层的判据是「非零 = 失败」,
|
||
/// 「拿不到退出码」和「退出码是某个非零值」对脚本是一回事。
|
||
fn exit_code(code: Option<i32>) -> i32 {
|
||
/// 重开失败 / 拿不到退出码时的兜底码(与 `main` 的「参数或运行错误」一致)。
|
||
const RESPAWN_FAILED: i32 = 2;
|
||
code.unwrap_or(RESPAWN_FAILED)
|
||
}
|
||
|
||
/// 构造降级重开的子进程命令(**可测**:program / argv / env 都能在测试里断言)。
|
||
///
|
||
/// `program` / `args` 是参数而不是在函数里读 `std::env`,唯一目的就是让测试能喂一个假命令
|
||
/// (`cmd /c exit 7` / `sh -c "exit 7"`)来验退出码透传——真用 `current_exe()` 就没法测了。
|
||
fn fallback_command(
|
||
program: &std::path::Path,
|
||
args: impl IntoIterator<Item = std::ffi::OsString>,
|
||
fallback: Kind,
|
||
) -> std::process::Command {
|
||
let mut command = std::process::Command::new(program);
|
||
command.args(args).envs(fallback_env(fallback));
|
||
// 三路 stdio **显式**继承。`Command` 默认就是继承,但显式写出来有两个作用:
|
||
// ① 将来为了「安静」加上 `.stderr(Stdio::null())` 时,diff 里看得见;
|
||
// ② 降级那次的子进程日志(`[probe] 本次是降级重开`)**必须和父进程同一路输出**,
|
||
// 否则验收脚本只抓到父进程那两行具名错误,看不到降级后到底用什么跑起来了。
|
||
// 注:`Command` 没有 stdio 的 getter(stable 没有),所以这条**只能靠真跑一次**证明
|
||
// (见 §Windows 实测的 `interactive.log`:父子两段日志在同一个 stderr 文件里)。
|
||
command
|
||
.stdin(std::process::Stdio::inherit())
|
||
.stdout(std::process::Stdio::inherit())
|
||
.stderr(std::process::Stdio::inherit());
|
||
command
|
||
}
|
||
|
||
/// 按一次后端启动 iced。`backend` 决定这次写进 `ICED_BACKEND` 的唯一一项。
|
||
fn launch(
|
||
options: &Options,
|
||
families: Families,
|
||
flavor: SchemeFlavor,
|
||
backend: Kind,
|
||
) -> iced::Result {
|
||
std::env::set_var("ICED_BACKEND", backend.env_value());
|
||
|
||
let boot_options = options.clone();
|
||
|
||
let application = iced::application(
|
||
move || {
|
||
(
|
||
App::new(boot_options.clone(), families, flavor),
|
||
Task::done(Message::Boot),
|
||
)
|
||
},
|
||
App::update,
|
||
App::view,
|
||
)
|
||
.title(App::title)
|
||
.theme(App::theme)
|
||
.subscription(App::subscription)
|
||
.default_font(default_font(families))
|
||
.window(window::Settings {
|
||
size: WINDOW_SIZE,
|
||
min_size: Some(MIN_SIZE),
|
||
decorations: false,
|
||
resizable: true,
|
||
// M93 / D36:整窗 α 需要 `WS_EX_LAYERED`,但**这里给不了**——
|
||
// ① iced 0.14 的 `window::Settings` 没有扩展样式字段;
|
||
// ② winit 0.30 只在鼠标穿透(`IGNORE_CURSOR_EVENT`)时置这一位,`transparent: true`
|
||
// 走的是 DWM 那条路,与 `LWA_ALPHA` 无关;
|
||
// ③ 加扩展样式只有 `Get/SetWindowLongPtrW`,是 `unsafe`,而全仓 `unsafe` 只允许出现在
|
||
// `platform/win32/transparency.rs`(CI 的 `unsafe-scope` 是集合相等)。
|
||
// 所以窗口创建侧只负责开窗,样式补齐与 α 下发都在拿到句柄之后:
|
||
// `app::update` 的 `Message::WindowHandle` → `window_alpha::dispatch`(先 ensure_layered)。
|
||
transparent: false,
|
||
..window::Settings::default()
|
||
});
|
||
|
||
match options.scale_factor {
|
||
Some(scale) => application.scale_factor(move |_| scale).run(),
|
||
None => application.run(),
|
||
}
|
||
}
|
||
|
||
/// 渲染后端探测 + 写 `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());
|
||
eprintln!(
|
||
"[probe] 本次是降级重开({}={}):跳过探测 -> ICED_BACKEND={}",
|
||
renderer::FALLBACK_ENV,
|
||
kind.env_value(),
|
||
kind.env_value()
|
||
);
|
||
eprintln!("[probe] {}", decision.describe());
|
||
return decision;
|
||
}
|
||
|
||
let probe_started = Instant::now();
|
||
// 只有 `auto` 需要探测:`cpu` 与 `gpu` 的决策与「有没有硬件 GPU」无关,
|
||
// 而枚举 13 个适配器在本机要 0.65~1.2 s(实测),纯属白花。
|
||
let needs_probe = options.backend == crate::Backend::Auto;
|
||
let probe = if needs_probe {
|
||
probe::Probe::run()
|
||
} else {
|
||
probe::Probe::default()
|
||
};
|
||
let probe_ms = probe_started.elapsed().as_secs_f64() * 1000.0;
|
||
|
||
// `ULL_PREFLIGHT=1` 时先自己验一遍 wgpu(判据表第 7 行);默认不跑,见 preflight 模块文档。
|
||
let preflight = renderer::preflight::run_if_requested();
|
||
let decision = probe::apply_with_preflight(options.backend, &probe, preflight.as_ref());
|
||
|
||
if cfg!(feature = "gpu") {
|
||
if needs_probe {
|
||
eprintln!("[probe] 探测耗时 {probe_ms:.0} ms(wgpu 实例 + 全后端适配器枚举)");
|
||
} else {
|
||
eprintln!(
|
||
"[probe] 探测耗时 {probe_ms:.0} ms(--renderer={} 与硬件无关,跳过枚举)",
|
||
options.backend.name()
|
||
);
|
||
}
|
||
}
|
||
decision
|
||
}
|
||
|
||
/// 默认字体(正文档解析出来的族)。
|
||
fn default_font(families: Families) -> iced::Font {
|
||
iced::Font {
|
||
family: iced::font::Family::Name(families.text),
|
||
..iced::Font::DEFAULT
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[path = "boot/tests.rs"]
|
||
mod tests;
|