Files
Unlucky-Launcher/crates/app-iced/src/app/boot.rs
T
AskaEth 53b27eac45 fix(renderer): M97 r2 —— 退出码透传拆成可测单元(构造 / 执行),4 个变异全抓
上一轮把降级重开的 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 第一次跑就这么被骗了三个变异(假绿第十种形态),
  这条写给将来改这套测试的人。
2026-11-03 00:26:56 +08:00

312 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 启动:后端探测 → 字体求值 → `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;