diff --git a/crates/app-iced/src/app/boot.rs b/crates/app-iced/src/app/boot.rs index 82b93a1..897c7dd 100644 --- a/crates/app-iced/src/app/boot.rs +++ b/crates/app-iced/src/app/boot.rs @@ -146,7 +146,7 @@ fn fallback_env(fallback: Kind) -> [(&'static str, &'static str); 2] { fn respawn(failed: Kind, fallback: Kind) -> ! { let exe = std::env::current_exe().unwrap_or_else(|err| { eprintln!("[gpu] 降级重开失败:拿不到自身路径({err})"); - std::process::exit(2); + std::process::exit(exit_code(None)); }); eprintln!( "[gpu] 降级 → {}({} 已失败,原因见上两行;换一个进程重开:winit 不允许同进程建两次事件循环)。\ @@ -155,23 +155,56 @@ fn respawn(failed: Kind, fallback: Kind) -> ! { failed.name() ); - let status = std::process::Command::new(exe) - .args(std::env::args().skip(1)) - .envs(fallback_env(fallback)) - .status(); + let status = fallback_command(&exe, std::env::args_os().skip(1), fallback).status(); // 退出码**透传**子进程的:上层脚本/CI 靠它判断这次到底是成功还是失败。 - // 子进程被信号杀掉时 `code()` 是 `None`(Unix 才有),那种情况退回 2,别假装成功。 + // 写错这一段的失效方式是「安静地给出一个错结论」——不崩、不静默降级、不无限重开, + // 只是让 CI 把失败当成功。所以它拆成 [`exit_code`] 这个纯函数,由测试钉住。 let code = match status { - Ok(status) => status.code().unwrap_or(2), + Ok(status) => exit_code(status.code()), Err(err) => { eprintln!("[gpu] 降级重开失败:{err}"); - 2 + exit_code(None) } }; std::process::exit(code) } +/// 子进程退出码 → 本进程退出码。 +/// +/// `None` = 子进程被信号杀死(只有 Unix 会这样,`ExitStatus::code` 返回 `None`)。 +/// 这种情况走兜底码而**不是** panic,也不是 0:上层的判据是「非零 = 失败」, +/// 「拿不到退出码」和「退出码是某个非零值」对脚本是一回事。 +fn exit_code(code: Option) -> 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, + 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, @@ -274,98 +307,5 @@ fn default_font(families: Families) -> iced::Font { } #[cfg(test)] -mod tests { - use super::*; - use crate::renderer::decision; - - fn probe(device_type: &'static str) -> crate::Probe { - crate::Probe { - adapters: vec![crate::probe::AdapterSummary { - name: "adapter".to_owned(), - device_type, - }], - hardware: !matches!(device_type, "Cpu" | "Other"), - } - } - - /// **降级重开到底传了什么**(M97 r1 p2-1 的守门)。 - /// - /// 抓得住:`fallback_env` 少写/写错任一条——两条都是静默失效型: - /// 漏 `ICED_BACKEND` ⇒ 子进程又去试 wgpu(白开一次窗);漏 `ULL_GPU_FALLBACK` ⇒ **无限重开**。 - /// 抓不住:`Command` 本身没被建起来(那要真起进程,见 §「Windows 实测」)。 - #[test] - fn fallback_env_carries_the_backend_and_the_gate() { - for fallback in [Kind::TinySkia, Kind::Wgpu] { - let env = fallback_env(fallback); - assert_eq!( - env.len(), - 2, - "就这两条,多一条少一条都要在这里改文档:{env:?}" - ); - assert!( - env.contains(&("ICED_BACKEND", fallback.env_value())), - "子进程必须直接用 {fallback:?} 跑,实际 {env:?}" - ); - assert!( - env.contains(&(renderer::FALLBACK_ENV, fallback.env_value())), - "缺防无限重开的闸门 {FALLBACK_ENV:?},实际 {env:?}", - FALLBACK_ENV = renderer::FALLBACK_ENV - ); - } - } - - /// 闸门的另一半:子进程那一轮的决策**不能再有兜底**,否则失败后继续 respawn ⇒ 无限递归。 - /// - /// 抓得住:`fallback_round` 的 `fallback` 被写成 `Some(..)`(自杀式循环)。 - /// 抓不住:`forced_fallback()` 没被 `boot_probe` 调用(那是环境变量的读,见下一条的说明)。 - #[test] - fn fallback_round_never_degrades_again() { - for kind in [Kind::TinySkia, Kind::Wgpu] { - let decision = decision::fallback_round(kind); - assert_eq!(decision.primary, kind); - assert_eq!( - decision.fallback, None, - "降级重开那一次不许再有兜底,否则会无限递归重开" - ); - assert_eq!(decision.reason, "fallback-after-failure"); - } - } - - /// 判据表第 8 行 vs 第 9 行:**交互降级 / 取证拒绝**,两条路的策略不同。 - /// - /// 抓得住:`--screenshot` 分支被改回「也降级」(那正是 M97 要杀的「取证工具自己换后端」)。 - /// 抓不住:`screenshot` 传进来的时机(那是 `run()` 的接线,见 Windows 实测记录)。 - #[test] - fn evidence_start_refuses_instead_of_degrading() { - let auto = decision::decide(crate::Backend::Auto, &probe("IntegratedGpu"), true); - let evidence = std::path::PathBuf::from("out.png"); - - assert_eq!( - on_failure(auto, Some(&evidence)), - Some(OnFailure::Refuse), - "取证启动 + wgpu 起不来 ⇒ 拒绝出图" - ); - assert_eq!( - on_failure(auto, None), - Some(OnFailure::Respawn(Kind::TinySkia)), - "交互启动 + wgpu 起不来 ⇒ 换进程降级" - ); - } - - /// 没有兜底可用的两行(强制 CPU / 未编 wgpu):失败就原样把错误交回去,不许 respawn。 - #[test] - fn no_fallback_means_no_respawn() { - let cpu = decision::decide(crate::Backend::Cpu, &probe("IntegratedGpu"), true); - let evidence = std::path::PathBuf::from("out.png"); - assert_eq!(cpu.fallback, None); - assert_eq!(on_failure(cpu, None), None); - assert_eq!( - on_failure(cpu, Some(&evidence)), - None, - "强制 CPU 也不该走到「拒绝降级」那条提示上(它压根没想上 GPU)" - ); - - let not_compiled = decision::decide(crate::Backend::Auto, &probe("IntegratedGpu"), false); - assert_eq!(on_failure(not_compiled, None), None); - } -} +#[path = "boot/tests.rs"] +mod tests; diff --git a/crates/app-iced/src/app/boot/tests.rs b/crates/app-iced/src/app/boot/tests.rs new file mode 100644 index 0000000..3765fd0 --- /dev/null +++ b/crates/app-iced/src/app/boot/tests.rs @@ -0,0 +1,187 @@ +//! `app/boot.rs` 的单元测试 + **变异验证说明**(M97 r1)。 +//! +//! 放独立文件(同 `pages/harness/tests.rs`)是为了让 `boot.rs` 留在 400 行内。 + +//! ## 变异验证怎么做(M97 r1 的教训,改测试前先读这段) +//! +//! 这批测试的价值不是「跑绿了」,而是「把实现改坏会红」。做变异时**只在目标函数体内替换**, +//! 并且把测试里的同一字面量排除在外: +//! +//! ```text +//! # // 示意(真实脚本是一次性的,没入库) +//! def in_body(path, marker, changes): +//! """按函数体范围替换。**不能**全局 sed:那会连测试期望里的同一字面量一起改掉, +//! 于是实现和断言被换成同一个错值 ⇒ 测试照样绿 ⇒ 验证的是变异脚本自己。""" +//! ``` +//! +//! M97 r1 第一次跑变异时,M1/M2/M3 三个变异「照样绿」——不是测试弱,是脚本用全局 `sed` +//! 把断言一起改了。这类假绿最阴:变异验证本来是我们判断「测试够不够」的终极手段, +//! 手段本身假绿时,它还会附赠「已验证」的强信心。 + +use super::*; + +use crate::renderer::decision; +use std::ffi::{OsStr, OsString}; +use std::path::Path; + +fn probe(device_type: &'static str) -> crate::Probe { + crate::Probe { + adapters: vec![crate::probe::AdapterSummary { + name: "adapter".to_owned(), + device_type, + }], + hardware: !matches!(device_type, "Cpu" | "Other"), + } +} + +/// **降级重开到底传了什么**(M97 r1 p2-1 的守门)。 +/// +/// 抓得住:`fallback_env` 少写/写错任一条——两条都是静默失效型: +/// 漏 `ICED_BACKEND` ⇒ 子进程又去试 wgpu(白开一次窗);漏 `ULL_GPU_FALLBACK` ⇒ **无限重开**。 +/// 抓不住:`Command` 本身没被建起来(那要真起进程,见 §「Windows 实测」)。 +#[test] +fn fallback_env_carries_the_backend_and_the_gate() { + for fallback in [Kind::TinySkia, Kind::Wgpu] { + let env = fallback_env(fallback); + assert_eq!( + env.len(), + 2, + "就这两条,多一条少一条都要在这里改文档:{env:?}" + ); + assert!( + env.contains(&("ICED_BACKEND", fallback.env_value())), + "子进程必须直接用 {fallback:?} 跑,实际 {env:?}" + ); + assert!( + env.contains(&(renderer::FALLBACK_ENV, fallback.env_value())), + "缺防无限重开的闸门 {FALLBACK_ENV:?},实际 {env:?}", + FALLBACK_ENV = renderer::FALLBACK_ENV + ); + } +} + +/// 闸门的另一半:子进程那一轮的决策**不能再有兜底**,否则失败后继续 respawn ⇒ 无限递归。 +/// +/// 抓得住:`fallback_round` 的 `fallback` 被写成 `Some(..)`(自杀式循环)。 +/// 抓不住:`forced_fallback()` 没被 `boot_probe` 调用(那是环境变量的读,见下一条的说明)。 +#[test] +fn fallback_round_never_degrades_again() { + for kind in [Kind::TinySkia, Kind::Wgpu] { + let decision = decision::fallback_round(kind); + assert_eq!(decision.primary, kind); + assert_eq!( + decision.fallback, None, + "降级重开那一次不许再有兜底,否则会无限递归重开" + ); + assert_eq!(decision.reason, "fallback-after-failure"); + } +} + +/// 判据表第 8 行 vs 第 9 行:**交互降级 / 取证拒绝**,两条路的策略不同。 +/// +/// 抓得住:`--screenshot` 分支被改回「也降级」(那正是 M97 要杀的「取证工具自己换后端」)。 +/// 抓不住:`screenshot` 传进来的时机(那是 `run()` 的接线,见 Windows 实测记录)。 +#[test] +fn evidence_start_refuses_instead_of_degrading() { + let auto = decision::decide(crate::Backend::Auto, &probe("IntegratedGpu"), true); + let evidence = std::path::PathBuf::from("out.png"); + + assert_eq!( + on_failure(auto, Some(&evidence)), + Some(OnFailure::Refuse), + "取证启动 + wgpu 起不来 ⇒ 拒绝出图" + ); + assert_eq!( + on_failure(auto, None), + Some(OnFailure::Respawn(Kind::TinySkia)), + "交互启动 + wgpu 起不来 ⇒ 换进程降级" + ); +} + +/// **构造侧**:argv 原样透传、两组 env 都在、program 就是传进来的那个。 +/// +/// 抓得住:重开时丢掉用户参数(`--screenshot` / `--screen` / `--data-dir` 全没了, +/// 子进程会按默认参数跑,那是最难查的一种「重开后行为变了」)、env 少传(M97 r1 的 p2-1)。 +/// 抓不住:stdio 继承(`Command` 没有 stdio getter,只能真跑一次,见 `fallback_command` 的注释)。 +#[test] +fn fallback_command_passes_program_argv_and_envs() { + let args = ["--renderer=auto", "--screenshot", "out.png"]; + let command = fallback_command( + Path::new("unlucky-launcher-iced"), + args.iter().map(OsString::from), + Kind::TinySkia, + ); + + assert_eq!( + command.get_program(), + OsStr::new("unlucky-launcher-iced"), + "program 必须是传进来的那个(生产里是 current_exe)" + ); + assert_eq!( + command.get_args().collect::>(), + args.iter().map(OsStr::new).collect::>(), + "argv 必须原样透传,一条不漏、顺序不变" + ); + let envs: Vec<_> = command.get_envs().collect(); + for (key, value) in fallback_env(Kind::TinySkia) { + assert!( + envs.contains(&(OsStr::new(key), Some(OsStr::new(value)))), + "重开命令缺 {key}={value},实际 {envs:?}" + ); + } +} + +/// **执行侧**:子进程退出 7 → 父进程拿到 7(不是 0、不是自己造的值)。 +/// +/// 抓得住:`exit_code` 被改成恒 0 / 恒 1 / 写成 `status.success().into()`。 +/// 抓不住:生产里 `current_exe()` 那个 program(测试用假命令,见 `fallback_command` 注释)。 +#[test] +fn fallback_command_really_passes_the_child_exit_code() { + #[cfg(windows)] + let (program, args) = ("cmd", vec!["/c", "exit", "7"]); + #[cfg(unix)] + let (program, args) = ("sh", vec!["-c", "exit 7"]); + + let status = fallback_command( + Path::new(program), + args.iter().map(OsString::from), + Kind::TinySkia, + ) + .status() + .expect("假子进程必须能起来"); + assert_eq!( + exit_code(status.code()), + 7, + "子进程退出 7,父进程必须透传 7;{status:?}" + ); +} + +/// 子进程**被信号杀死**(`code()` 为 `None`):走兜底码,不 panic、不当成功。 +/// +/// 这是纯函数测试,所以在 Windows 上也能跑(真造一个信号死亡的 `ExitStatus` +/// 需要 `ExitStatusExt`,只有 Unix 有;而这里测的正是「拿不到码」那一刻的行为)。 +#[test] +fn signal_death_falls_back_to_a_nonzero_code() { + assert_eq!(exit_code(Some(0)), 0, "成功必须透传 0"); + assert_eq!(exit_code(Some(7)), 7); + let fallback = exit_code(None); + assert_ne!(fallback, 0, "拿不到退出码绝不能当成功"); + assert_eq!(fallback, 2, "兜底码与 main 的「参数或运行错误」一致"); +} + +/// 没有兜底可用的两行(强制 CPU / 未编 wgpu):失败就原样把错误交回去,不许 respawn。 +#[test] +fn no_fallback_means_no_respawn() { + let cpu = decision::decide(crate::Backend::Cpu, &probe("IntegratedGpu"), true); + let evidence = std::path::PathBuf::from("out.png"); + assert_eq!(cpu.fallback, None); + assert_eq!(on_failure(cpu, None), None); + assert_eq!( + on_failure(cpu, Some(&evidence)), + None, + "强制 CPU 也不该走到「拒绝降级」那条提示上(它压根没想上 GPU)" + ); + + let not_compiled = decision::decide(crate::Backend::Auto, &probe("IntegratedGpu"), false); + assert_eq!(on_failure(not_compiled, None), None); +}