fix(acceptance): M98 r1 —— [window] 绑定真 settings、语言维度补断言、boot.rs 拆分

复审 r1(reviewer-accscope-code,p2-2items / fix-then-merge)的两条阻塞 + 四条顺手的 p3。

## p2-1 `[window]` 的三个窗口属性曾是格式串常量(U1:常量冒充观测结果)

第一版把 `resizable=true decorations=false transparent=false` 写在格式化串里,与交给 iced 的
`window::Settings` 没有任何绑定;定向变异把 `settings.decorations` 改成 `true`,**371 个测试全绿**,
而验收包里 `[caps]` 与 `[window]` 会互相矛盾。

- 新增 `acceptance::window::WindowFacts` + `from_settings()`:字段只能从**真实 settings** 抽出来,
  没有手抄入口;`window_text()` 逐字段打印。
- 窗口设置收成单一产地 `app/boot/diagnostics::window_settings()`,`launch()` 与 `[window]`/`[caps]`
  两行日志共用同一个 `&window::Settings`;`pages/harness.rs` 同样从自己那份 settings 取。
- 守门测试 `window_settings_match_what_the_acceptance_logs_claim`,含「from_settings 必须逐字段
  搬运」的反向断言。变异实证:`window_settings` 的 `decorations → true` ⇒ 红并点名;
  `from_settings` 里写死 `decorations: false` ⇒ 红(第一次写测试时这条漏抓,补强后实测红)。

## p2-2 语言维度空转、且没有任何结论断言

`lang::resolve` 是 setting → 平台源 → env,macOS 的 AppleLanguages 几乎恒有值 ⇒ `LANG=zh_CN` /
`LANG=en_US` 两档同值,而原脚本只断言「`[i18n]` 这行存在」——整条链退化成
`source=default lang=en` 时核心集一条都不红。

- 脚本加两条结论断言:`^\[i18n\].*platform=applelanguages:[A-Za-z-]+` 与 ` source=applelanguages`
  (退化日志上实测红、健康日志上实测绿)。
- `REPORT.md` 语言行下注明:env 优先级低于平台源,正常 Mac 上两档同值,这一维验的是「平台源
  真读到了 + 中文字体回退」,不是中英对照。

## p3(复审 + 第二路自跑复核同源的三条)

- `[perf] boot probe=0ms` → `probe=skipped`(没计时就不报数;降级重开那一轮改为**真计时**)。
  变异实证:把 skipped 写成 0ms ⇒ `perf_lines_carry_measured_millis` 红。
- `[env] gpu=` 排序改成 `DiscreteGpu > IntegratedGpu > 其它`:本机原来报核显,而 iced 实际选的是
  独显(现在报 `AMD Radeon RX 6600 XT [DiscreteGpu]`,与 `Selected:` 一致)。
- `[i18n] setting=<未设置>` → `<未接线>`(配置侧根本没读取点,写「未设置」等于把「我们没读」
  说成「用户没设」)。
- `[backend] final=` 失败行:`refuse` / `none` 两条路是 `final=none`(什么都没跑起来),只有
  `respawn` 才是兜底名;`pages/harness.rs` 不再写死 `via="refuse"`,改用共用的 `failure_via()`。
  实跑负例:`[backend] final=none result=failed primary=wgpu fallback=tiny-skia via=refuse`。
- 脚本 `[tick]` 那处唯一未锚定的 `field()`:加 `^` 锚定、正则改 `clock=[a-z0-9-]+`(真值是
  `idle-1hz`,旧 `[a-z]+` 把它截成 `idle`,靠前缀巧合通过);断言也改成 `clock=idle-1hz`。
- 脚本 `[backend] final` 取值改 `field_backend()`:优先 `result=started`(respawn 场景一条日志里
  有父 failed + 子 started 两条,旧 `head -1` 会把「这次没跑起来」写进报告)。
- 脚本 `exec 3>&1` 全程留痕(终端提示也进 `logs/00-run.log`)、跑前清空 `$OUT/shots/`、
  python3 缺失时在 REPORT 里写明「几何列未采」而不是留空。
- `renderer/mod.rs` 补一段依据:为什么「单项 ICED_BACKEND ⇒ 没有静默回退」成立(`iced_tiny_skia`
  的 `with_backend` 只接受 `tiny-skia`),以及它依赖 iced 实现细节的两层缓解。

## 结构

`app/boot.rs` 400 行零余量 → 拆成 `boot.rs`(248) + `boot/fallback.rs`(117,兜底/重开/退出码) +
`boot/diagnostics.rs`(110,窗口设置与四段报告);`run()` 回到「探测 → 字体 → 打印 → 起 iced」。
`acceptance` 再把 `[window]` 拆成 `window.rs`(142)。

门禁:`cargo fmt --check` / `clippy --all-targets -- -D warnings` / `test`(373 lib + 6 integration)全绿。
This commit is contained in:
AskaEth committed 2026-11-03 12:59:57 +08:00
1 parent f986981c12
commit 8153e54f87
10 files changed
+726 -385

No files matched your search

+67 -219
View File
@@ -1,56 +1,49 @@
//! 启动:后端探测 → 字体求值 → `iced::application(..).run()`,外加 M98 的八类验收诊断。
//! 启动编排:**探测 → 字体 → 打印诊断 → 起 iced**(四步,别把它撑回一坨)。
//!
//! ## M97:起步后端起不来时怎么办(两条路,策略不同,别合成一条)
//! ## 三条纪律(每条都对应一次返工)
//!
//! `ICED_BACKEND` 永远是**单项**(见 [`crate::renderer`] 的判据表)。单项的代价是 iced 不再自己
//! 回退,于是「wgpu 起不来」会变成 `iced::Error::GraphicsCreationFailed`——**这正是我们要的**:
//! 那里面装着 wgpu 的具名错误(哪个 backend、卡在 adapter 还是 device)。
//! 1. **后端起不来时的两条路策略不同**(判据表第 8/9 行):交互启动换进程重开、取证启动
//! (`--screenshot`)拒绝出图 —— 实现与测试都在 [`fallback`]。
//! 2. **窗口设置只有一个产地**([`diagnostics::window_settings`]),`[window]` / `[caps]` 两行日志
//! 从**同一个** `&window::Settings` 取字段。M98 r1 p2-1 的教训:把 `decorations=false` 写进
//! 格式化串,等于让日志与真实行为可以各走各的(踩坑记录 U1)。
//! 3. **没测过的就写 `skipped`**,不许拿 `0ms` 冒充实测([`diagnostics::report_font`] 的 `probe`)。
//!
//! - **交互启动**:换一个**进程**用 `tiny-skia` 重开([`OnFailure::Respawn`])——安全网,用户还能用。
//! - **取证启动**(带 `--screenshot`,含 `--screen=gallery`):**拒绝出图**、退出码 1
//! ([`OnFailure::Refuse`])——换了后端出的图会让报告撒谎。
//!
//! 为什么必须换进程:Windows 实测本进程里再 `run()` 一次直接 panic
//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)。别再猜着改回去。
//!
//! ## M98:诊断的落点
//!
//! 八类日志由 [`crate::renderer::acceptance`] 排版;本文件只在**正确的时刻**把真值喂给它(探测
//! 产物 / 窗口设置 / 实测 `Instant`);判据与证据的边界写在那边的模块文档里。
//! 为什么重开必须是**换进程**:Windows 实测本进程里再 `run()` 一次直接 panic
//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)。
use std::time::{Duration, Instant};
use iced::{window, Task};
use iced::Task;
use unlucky_design::color::SchemeFlavor;
use crate::cli::Options;
use crate::fonts;
use crate::probe;
use crate::renderer::acceptance;
use crate::renderer::{self, Decision, Kind};
use crate::renderer::{self, acceptance, Decision};
use crate::theme::Families;
use super::message::Message;
use super::state::{App, STARTED};
use super::{APP_TITLE, MIN_SIZE, WINDOW_SIZE};
use super::{MIN_SIZE, WINDOW_SIZE};
mod diagnostics;
mod fallback;
use fallback::{on_failure, respawn, OnFailure};
/// 启动 iced(阻塞到退出)。`--diff` 不走这里,见 `main.rs`。
pub(crate) fn run(options: Options) -> iced::Result {
let _ = STARTED.set(Instant::now());
let boot_started = Instant::now();
// 贵的诊断(适配器枚举 / `defaults` / `reg.exe`)只在验收模式下跑;便宜的行恒开。
if acceptance::enabled() {
acceptance::announce();
}
// ① 探测(判据取在哪、日志在 `report_boot` 里统一打)
let boot = boot_probe(&options);
let decision = boot.decision;
acceptance::report_backend_plan(&decision, options.backend);
if acceptance::enabled() {
acceptance::report_startup(&boot.probe, options.scale_factor);
}
diagnostics::report_boot(&options, &boot.probe, &decision);
// ② 字体(`fonts` 自己打两行既有日志;新加的那行在 `report_font` 里)
let font_started = Instant::now();
let resolved = fonts::resolve();
let font_ms = font_started.elapsed();
@@ -61,35 +54,24 @@ pub(crate) fn run(options: Options) -> iced::Result {
"[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换字族)要处理"
);
}
if acceptance::enabled() {
acceptance::report_font_chain(resolved.platform);
}
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);
// `[theme]`:系统那条是异步的 ⇒ `follows_system=true` 时 `flavor=pending`,真值随后由
// `app/update.rs` 的 `[theme] 系统主题 = …` 给出(两行合起来才是完整答案)。
let (source, follows) = acceptance::theme_source(
options.screenshot_theme.is_some(),
options.theme.is_some(),
options.screenshot.is_some(),
diagnostics::report_theme(&options, flavor);
diagnostics::report_font(
resolved.platform,
boot.probe_ms,
font_ms,
boot_started.elapsed(),
);
let explicit = (!follows).then_some(if flavor.is_dark() { "dark" } else { "light" });
acceptance::report_theme(source, explicit, follows);
if acceptance::enabled() {
acceptance::report_perf_boot(boot.probe_ms, font_ms, boot_started.elapsed());
}
// 起步后端起不来时按判据表第 8/9 行分流(交互降级 vs 取证拒绝)。具名原因在分流之前
// 就打到同一个 stderr 上了。
// ④ 起 iced(失败按判据表第 8/9 行分流;具名原因在分流之前就打到同一个 stderr 上了)
let launch_started = Instant::now();
let result = match launch(&options, families, flavor, decision.primary) {
Err(iced::Error::GraphicsCreationFailed(error)) => {
@@ -119,141 +101,29 @@ pub(crate) fn run(options: Options) -> iced::Result {
};
let launch_elapsed = launch_started.elapsed();
// `[backend] final`:**结果**(不是请求)。判据是单项 `ICED_BACKEND`(判据表第 1~7 行):
// 候选只有一项 ⇒ 进程能起来就必定是它,没有静默回退的空间。
match &result {
Ok(()) => acceptance::report_backend_started(decision.primary, launch_elapsed),
Err(error) => {
let named = renderer::named_graphics_error(error).unwrap_or_else(|| error.to_string());
acceptance::report_backend_failed(
decision.primary,
decision.fallback,
acceptance::failure_via(decision.fallback.is_some(), options.screenshot.is_some()),
&named,
);
}
}
if acceptance::enabled() {
acceptance::report_perf_session(
if options.screenshot.is_some() {
"screenshot"
} else {
"interactive"
},
launch_elapsed,
result.is_ok(),
);
}
diagnostics::report_outcome(
&decision,
result.as_ref().err().map(|error| {
renderer::named_graphics_error(error).unwrap_or_else(|| error.to_string())
}),
acceptance::failure_via(decision.fallback.is_some(), options.screenshot.is_some()),
if options.screenshot.is_some() {
"screenshot"
} else {
"interactive"
},
launch_elapsed,
);
result
}
/// 起步后端失败后的去向(判据表第 8 行 vs 第 9 行——**两条路策略不同,别合成一条**)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OnFailure {
/// 交互启动:安全网优先,换进程用这个后端重开。
Respawn(Kind),
/// 取证启动:红着退出,**不出图**。
Refuse,
}
/// 纯判据:起步后端失败时走哪条路。
///
/// `evidence` 非空 = 本次是取证启动(带 `--screenshot`);返回 `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` 后端重开,然后带着它的退出码退出。
///
/// 只在**交互启动**时走到这里(取证启动宁可红着退出也不换后端出图);子进程看到
/// [`renderer::FALLBACK_ENV`] 必须跳过探测,否则无限递归重开(闸门在 `boot_probe` 入口)。
fn respawn(failed: Kind, fallback: Kind, error: &str) -> ! {
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()
);
// `[backend] final` 一行把「哪个后端失败了、去了哪、为什么」收在一起(可 grep 的单一结论行)。
acceptance::report_backend_failed(failed, Some(fallback), "respawn", error);
let status = fallback_command(&exe, std::env::args_os().skip(1), fallback).status();
// 退出码**透传**子进程的:上层脚本/CI 靠它判断成败。写错的失效方式是「安静地给出一个错
// 结论」——不崩、不静默降级、不无限重开,只是让 CI 把失败当成功。
let code = match status {
Ok(status) => exit_code(status.code()),
Err(err) => {
eprintln!("[gpu] 降级重开失败:{err}");
exit_code(None)
}
};
std::process::exit(code)
}
/// 子进程退出码 → 本进程退出码。
///
/// `None` = 子进程被信号杀死(只有 Unix):走兜底码而**不是** 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()` 就没法测。
///
/// 三路 stdio **显式**继承:子进程日志必须和父进程同一路输出,否则验收只抓到父进程那两行具名
/// 错误。`Command` 没有 stdio 的 getter(stable 没有),所以这条只能靠真跑一次证明。
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));
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,
backend: renderer::Kind,
) -> iced::Result {
std::env::set_var("ICED_BACKEND", backend.env_value());
@@ -265,34 +135,9 @@ fn launch(
(WINDOW_SIZE.width, WINDOW_SIZE.height),
(MIN_SIZE.width, MIN_SIZE.height),
);
let settings = window::Settings {
size: iced::Size::new(size.0, size.1),
min_size: Some(MIN_SIZE),
decorations: false,
resizable: true,
// M93 / D36:整窗 α 需要 `WS_EX_LAYERED`,但这里给不了——`window::Settings` 没有扩展样式
// 字段,写它只有 `Get/SetWindowLongPtrW`(`unsafe`,全仓只允许出现在
// `platform/win32/transparency.rs`)。所以 α 下发在拿到句柄之后(`Message::WindowHandle`)。
transparent: false,
..window::Settings::default()
};
// 值**直接取自**上面那个 `settings`(另抄一份就会漂;验收靠「日志说的 = 给窗口的」才有意义)。
acceptance::report_window(
(settings.size.width, settings.size.height),
Some((MIN_SIZE.width, MIN_SIZE.height)),
options.scale_factor,
size_source,
Some(APP_TITLE),
);
let exe = std::env::current_exe().ok();
acceptance::report_caps(
exe.as_deref(),
settings.transparent,
settings.resizable,
settings.decorations,
);
let settings = diagnostics::window_settings(size);
// 两行日志与开窗用的是**同一个** settings(见模块文档纪律 2)
diagnostics::report_window(&settings, options.scale_factor, size_source);
let application = iced::application(
move || {
@@ -316,14 +161,14 @@ fn launch(
}
}
/// [`boot_probe`] 的产物:判据 + 探测到的适配器 + 探测耗时。
/// [`boot_probe`] 的产物:判据 + 探测到的适配器 + **探测耗时(没测就是 `None`)**。
///
/// 为什么把 `Probe` 带出来:`[env]` 的 `gpu=` / `hardware=` 必须是**这一次真枚举到的**结果
/// (再枚举一遍多花 0.65~1.2 s,还可能给出不同状态)。判据与诊断看同一份数据。
struct Boot {
decision: Decision,
probe: probe::Probe,
probe_ms: Duration,
probe_ms: Option<Duration>,
}
/// 渲染后端探测 + 写 `ICED_BACKEND`,返回判据产物(供 `run` 决定要不要换进程重开)。
@@ -340,44 +185,47 @@ fn boot_probe(options: &Options) -> Boot {
kind.env_value()
);
eprintln!("[probe] {}", decision.describe());
// 验收要 `[env]` 里的真实适配器:这一轮也枚举(否则日志会写 `adapters=0`,与事实相反
// ——GPU 刚刚才被枚举到、只是起不来)。只有验收开关打开时才付这 0.65~1.2 s。
let probe = if acceptance::enabled() {
probe::Probe::run()
// 验收要 `[env]` 里的真实适配器:这一轮**也枚举一次,并如实计时**(没计时就 `None`
// ⇒ 日志写 `probe=skipped`,不写 0ms)。
let (probe, probe_ms) = if acceptance::enabled() {
let started = Instant::now();
let probe = probe::Probe::run();
(probe, Some(started.elapsed()))
} else {
probe::Probe::default()
(probe::Probe::default(), None)
};
return Boot {
decision,
probe,
probe_ms: Duration::ZERO,
probe_ms,
};
}
let probe_started = Instant::now();
// 只有 `auto` 需要探测(`cpu`/`gpu` 的决策与硬件无关,枚举 13 个适配器本机要 0.65~1.2 s)。
// 验收模式例外:`[env]` 那行要 GPU 名,不管请求是哪个后端。
let needs_probe = options.backend == crate::Backend::Auto || acceptance::enabled();
let probe_started = Instant::now();
let probe = if needs_probe {
probe::Probe::run()
} else {
probe::Probe::default()
};
let probe_ms = probe_started.elapsed();
let probe_ms = needs_probe.then(|| probe_started.elapsed());
// `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") {
let ms = probe_ms.as_secs_f64() * 1000.0;
if needs_probe {
eprintln!("[probe] 探测耗时 {ms:.0} ms(wgpu 实例 + 全后端适配器枚举)");
} else {
eprintln!(
"[probe] 探测耗时 {ms:.0} ms(--renderer={} 与硬件无关,跳过枚举)",
match probe_ms {
Some(elapsed) => eprintln!(
"[probe] 探测耗时 {:.0} ms(wgpu 实例 + 全后端适配器枚举)",
elapsed.as_secs_f64() * 1000.0
),
None => eprintln!(
"[probe] 探测耗时 跳过(--renderer={} 与硬件无关,未枚举)",
options.backend.name()
);
),
}
}
Boot {
+110
View File
@@ -0,0 +1,110 @@
//! 启动阶段的**验收诊断编排**:把 `app/boot.rs` 的 `run()` 收成「探测 → 字体 → 打印 → 起 iced」。
//!
//! ## 为什么单独一个文件(M98 r1 p2-1 的根因)
//!
//! 第一版把 `[window] requested … resizable=true decorations=false transparent=false` 直接写在
//! 格式化串里,而 `boot.rs` 给窗口的 `settings` 是另一处常量 —— 两者**没有任何绑定**。定向变异
//! 把 `settings.decorations` 改成 `true`,371 个测试全绿,而验收包里 `[caps] decorations=true` 与
//! `[window] decorations=false` 会互相矛盾(踩坑记录 U1:常量冒充观测结果)。
//!
//! 现在:窗口设置由 [`window_settings`] **一处**产生,日志由 [`report_window`] 从**同一个
//! `&window::Settings`** 取字段。钉死它的守门测试在 `boot/tests.rs`(`window_settings_*`)。
//!
//! 另一件事:所有「跑没跑」都要能如实说。降级重开那一轮没计时就写 `probe=skipped`,
//! 不许拿 `0ms` 冒充一次实测(M98 r1 p3-1)。
use std::time::Duration;
use iced::window;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform as FontPlatform;
use crate::cli::Options;
use crate::probe::Probe;
use crate::renderer::{acceptance, Decision};
use super::MIN_SIZE;
use crate::app::APP_TITLE;
/// 产品窗口设置(**唯一产地**:`launch()` 用它开窗,`[window]` / `[caps]` 从它取字段)。
///
/// `ULL_WINDOW_SIZE` 的覆盖在 `boot.rs` 解析成 `size` 后从这里进来 —— 日志与真窗口永远同源。
pub(super) fn window_settings(size: (f32, f32)) -> window::Settings {
window::Settings {
size: iced::Size::new(size.0, size.1),
min_size: Some(MIN_SIZE),
// 自绘三键(M93):窗口**不许**带系统装饰,否则 macOS 上会出现原生标题栏 + 自绘三键两套。
decorations: false,
resizable: true,
// M93 / D36:整窗 α 需要 `WS_EX_LAYERED`,但这里给不了——`window::Settings` 没有扩展样式
// 字段,写它只有 `Get/SetWindowLongPtrW`(`unsafe`,全仓只允许出现在
// `platform/win32/transparency.rs`)。所以 α 下发在拿到句柄之后(`Message::WindowHandle`)。
transparent: false,
..window::Settings::default()
}
}
/// `[window] requested` + `[caps]` 两行,**都从同一个 `settings` 取**(不给手抄字面量的机会)。
pub(super) fn report_window(settings: &window::Settings, scale: Option<f32>, size_source: &str) {
let facts = acceptance::WindowFacts::from_settings(settings);
acceptance::report_window(&facts, scale, size_source, Some(APP_TITLE));
acceptance::report_caps(
std::env::current_exe().ok().as_deref(),
facts.transparent,
facts.resizable,
facts.decorations,
);
}
/// 第一段:`[acceptance] on` + `[backend] plan` +(验收模式)`[env]` / `[i18n]`。
pub(super) fn report_boot(options: &Options, probe: &Probe, decision: &Decision) {
if acceptance::enabled() {
acceptance::announce();
}
acceptance::report_backend_plan(decision, options.backend);
if acceptance::enabled() {
acceptance::report_startup(probe, options.scale_factor);
}
}
/// 第二段:`[font] chain` + `[perf] boot`(都要 `ULL_ACCEPTANCE=1`)。
///
/// `probe = None` = 这一轮**没有探测**(既不是 `auto` 也没开验收)⇒ 打 `probe=skipped`。
pub(super) fn report_font(
platform: FontPlatform,
probe: Option<Duration>,
font: Duration,
to_run: Duration,
) {
if acceptance::enabled() {
acceptance::report_font_chain(platform);
acceptance::report_perf_boot(probe, font, to_run);
}
}
/// 第三段:`[theme]`(恒开:它解释的是「这次主题凭什么是这个」)。
pub(super) fn report_theme(options: &Options, flavor: SchemeFlavor) {
let (source, follows) = acceptance::theme_source(
options.screenshot_theme.is_some(),
options.theme.is_some(),
options.screenshot.is_some(),
);
let explicit = (!follows).then_some(if flavor.is_dark() { "dark" } else { "light" });
acceptance::report_theme(source, explicit, follows);
}
/// 第四段:`[backend] final`(恒开)+ `[perf] session`(验收模式)。
///
/// `error` = 具名失败原因(`None` = 起来了);`via` 见 `renderer::acceptance::failure_via`。
/// `refuse` / `none` 两条路**什么都没跑起来** ⇒ `final=none`;只有 `respawn` 才有「换到哪个
/// 后端」可言(那一格是兜底名,子进程自己还会再打一遍自己的 plan/final)。
pub(super) fn report_outcome(
decision: &Decision,
error: Option<String>,
via: &str,
mode: &str,
elapsed: Duration,
) {
acceptance::report_outcome(decision, error.as_deref(), via, mode, elapsed);
}
+117
View File
@@ -0,0 +1,117 @@
//! 起步后端失败后的**兜底/重开**一簇:判据、进程环境交接、`respawn`、退出码透传。
//!
//! 从 `app/boot.rs` 拆出来的理由:这一簇**与启动编排零耦合**,但已有 5 条测试,而且它是
//! M97 判据表第 8/9 行的落点。`boot.rs` 那边只负责「什么时候调它」。
//!
//! ## 为什么必须换进程(别改回进程内重试)
//!
//! Windows 实测本进程里再 `run()` 一次直接 panic
//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`),winit 一个进程
//! 只能建一次事件循环。
use crate::renderer::Decision;
use crate::renderer::{self, acceptance, Kind};
/// 起步后端失败后的去向(判据表第 8 行 vs 第 9 行——**两条路策略不同,别合成一条**)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum OnFailure {
/// 交互启动:安全网优先,换进程用这个后端重开。
Respawn(Kind),
/// 取证启动:红着退出,**不出图**。
Refuse,
}
/// 纯判据:起步后端失败时走哪条路。
///
/// `evidence` 非空 = 本次是取证启动(带 `--screenshot`);返回 `None` = 没有兜底可用
/// (`--renderer=cpu` / 未编 wgpu),原样把错误交回去。抽成纯函数只为一个理由:这条分支以前
/// **没有任何测试**,`respawn` / `Refuse` 两条路可以被静默改反(M97 r1 p2-1 就是这么测出来的)。
pub(super) 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、漏 ② 无限重开),线上极难看出来。
pub(super) fn fallback_env(fallback: Kind) -> [(&'static str, &'static str); 2] {
[
("ICED_BACKEND", fallback.env_value()),
(renderer::FALLBACK_ENV, fallback.env_value()),
]
}
/// 安全网:起一个**新进程**用 `fallback` 后端重开,然后带着它的退出码退出。
///
/// 只在**交互启动**时走到这里(取证启动宁可红着退出也不换后端出图);子进程看到
/// [`renderer::FALLBACK_ENV`] 必须跳过探测,否则无限递归重开(闸门在 `boot_probe` 入口)。
pub(super) fn respawn(failed: Kind, fallback: Kind, error: &str) -> ! {
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()
);
// `[backend] final` 一行把「哪个后端失败了、去了哪、为什么」收在一起(可 grep 的单一结论行)。
// 这里 `final=` 装的是**兜底**(进程即将带着它重开);`via=respawn` 说明它是换进程去的。
acceptance::report_backend_failed(failed, Some(fallback), Some(fallback), "respawn", error);
let status = fallback_command(&exe, std::env::args_os().skip(1), fallback).status();
// 退出码**透传**子进程的:上层脚本/CI 靠它判断成败。写错的失效方式是「安静地给出一个错
// 结论」——不崩、不静默降级、不无限重开,只是让 CI 把失败当成功。
let code = match status {
Ok(status) => exit_code(status.code()),
Err(err) => {
eprintln!("[gpu] 降级重开失败:{err}");
exit_code(None)
}
};
std::process::exit(code)
}
/// 子进程退出码 → 本进程退出码。
///
/// `None` = 子进程被信号杀死(只有 Unix):走兜底码而**不是** panic、也不是 0——上层判据是
/// 「非零 = 失败」,「拿不到退出码」和「退出码是某个非零值」对脚本是一回事。
pub(super) 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()` 就没法测。
///
/// 三路 stdio **显式**继承:子进程日志必须和父进程同一路输出,否则验收只抓到父进程那两行具名
/// 错误。`Command` 没有 stdio 的 getter(stable 没有),所以这条只能靠真跑一次证明。
pub(super) 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));
command
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
command
}
+84
View File
@@ -18,9 +18,12 @@
//! 把断言一起改了。这类假绿最阴:变异验证本来是我们判断「测试够不够」的终极手段,
//! 手段本身假绿时,它还会附赠「已验证」的强信心。
use super::fallback::*;
use super::*;
use crate::renderer::acceptance::WindowFacts;
use crate::renderer::decision;
use crate::renderer::Kind;
use std::ffi::{OsStr, OsString};
use std::path::Path;
@@ -185,3 +188,84 @@ fn no_fallback_means_no_respawn() {
let not_compiled = decision::decide(crate::Backend::Auto, &probe("IntegratedGpu"), false);
assert_eq!(on_failure(not_compiled, None), None);
}
/// **窗口设置就是验收日志里那三个字段的来源**(M98 r1 p2-1 的守门)。
///
/// 抓得住:把 `decorations` 改回 `true`(自绘三键退回系统装饰)、把 `resizable` / `transparent`
/// 打开 —— 这三种改动都会让 `[window]` 与 `[caps]` 两行与代码自相矛盾,而在此之前**371 个测试
/// 全绿**(复审 M6 变异实证)。现在它们必须红在这里。
/// 抓不住:winit 是否真按这些设置建窗(那要真机看窗口,见判据表「谁能验证」列)。
#[test]
fn window_settings_match_what_the_acceptance_logs_claim() {
let settings = diagnostics::window_settings((1180.0, 760.0));
assert!(
!settings.decorations,
"M93 自绘三键:窗口不能带系统装饰(否则 macOS 上会出现两套标题栏)"
);
assert!(
settings.resizable,
"验收要验「能否缩放」,窗口必须声明可缩放"
);
assert!(
!settings.transparent,
"整窗透明只在 win32 拿到句柄后下发(D36),窗口创建侧不许打开它"
);
assert_eq!(
settings.min_size,
Some(MIN_SIZE),
"最小尺寸档(880×560)是矩阵的一格,不能被改动"
);
// 日志确实从上面这个 settings 取值(而不是另抄一份字面量)
let facts = WindowFacts::from_settings(&settings);
let line = crate::renderer::acceptance::window_text(
&facts,
None,
"default",
Some(super::super::APP_TITLE),
);
assert!(line.contains("resizable=true"), "{line}");
assert!(line.contains("decorations=false"), "{line}");
assert!(line.contains("transparent=false"), "{line}");
assert!(line.contains("size=1180x760"), "{line}");
assert!(line.contains("min=880x560"), "{line}");
// 抽字段这一步必须**忠实**:拿一份处处相反的 settings 进来,facts 必须跟着反
// (抓的是「from_settings 里又写死一份常量」这条回退路 —— M98 r1 M7 变异实证它当年漏抓)
let odd = iced::window::Settings {
size: iced::Size::new(640.0, 480.0),
min_size: None,
decorations: true,
resizable: false,
transparent: true,
..iced::window::Settings::default()
};
assert_eq!(
WindowFacts::from_settings(&odd),
WindowFacts {
size: (640.0, 480.0),
min: None,
resizable: false,
decorations: true,
transparent: true,
},
"from_settings 必须逐字段搬运,不能在内部再写死一份常量"
);
// 反向:把事实翻个身,日志必须跟着变(证明没有第二份真值)
let flipped = WindowFacts {
decorations: true,
..facts
};
let line = crate::renderer::acceptance::window_text(
&flipped,
None,
"default",
Some(super::super::APP_TITLE),
);
assert!(
line.contains("decorations=true"),
"日志必须跟着 settings 走:{line}"
);
}