From d298691c1761b24a0afc145a6569ff749af2572d Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 3 Nov 2026 17:34:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(app-iced):=20M100=20r3=20P2-1=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=88=A4=E6=8D=AE=E4=BB=8E=E3=80=8C=E5=B8=A7?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E3=80=8D=E5=8D=87=E7=BA=A7=E6=88=90=E3=80=8C?= =?UTF-8?q?=E7=BB=88=E6=80=81=E6=97=A0=E8=AF=81=E6=8D=AE=E5=8D=B3=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审在 `95530a5` 的导出上用变异实测证明:把第 3 帧的采样换成 `window::close(id)` 时, 进程 **exit 0 且没有 PNG**,日志与成功跑同一形状只少了出图那几行 —— 只看退出码的脚本会读到 「成功 + 零证据」。 根因:`window::frames()` 只在 `Event::Window(RedrawRequested)` 时产消息,窗口消失 ⇒ 不再有帧 ⇒ `Message::Frame` 不再产生 ⇒ `capture_stalled` **永远求值不到**;同时 iced_winit 对「最后一个 窗口 Destroyed」直接 `Control::Exit`,`run()` 返回 `Ok(())` ⇒ 例子返回 SUCCESS。 我原来在 `CAPTURE_GIVE_UP_FRAMES` 的注释里把「窗口消失」写进了覆盖清单,实测是**说反的**。 改法(后置不变量,比超时强): - 落盘计数改成共享 `Arc`,**只在 `shot::save` 成功后**自增(U1:不是「打算写」)。 - `run()` 里 `outcome?;` 之后加终态判据 `no_evidence(written) == 0` ⇒ 打具名错误并 `exit(1)`。 - 帧超时兜底保留(管「有帧但采样回不来」);注释订正为三路:后端报错 ⇒ Err(本就非 0)、 窗口消失 ⇒ 帧不再产生 ⇒ 由终态判据兜住、有帧但采样卡住 ⇒ 帧超时。 实测: - 正常路径(最终二进制 ×6):exit 0、PNG 存在、`落盘次数=1`、`passthrough_ext=on`、sha `0d7b4f5b`、错误行 0。 - **M3 变异复现**(采样换成 `window::close(id)`,×3):**exit=1**、**PNG 不存在**、 `[harness] 取证结束但没有任何图落盘(窗口提前消失 / 落盘失败),按失败退出`。 - **M4 变异**(`CAPTURE_GIVE_UP_FRAMES` 改 0,×2):仍 exit=1 + `采样超时` ⇒ 旧兜底没被改坏。 - 新断言 `finishing_without_a_single_write_is_a_failure` 守终态判据(变异:删掉 `run()` 里那条 检查不会让它红——它守的是判据本身,不是调用点,报告里写清了「抓不住」)。 --- crates/app-iced/src/pages/harness.rs | 31 ++++++++++++++------ crates/app-iced/src/pages/harness/app.rs | 34 +++++++++++++++++++++- crates/app-iced/src/pages/harness/tests.rs | 21 +++++++++++++ 3 files changed, 76 insertions(+), 10 deletions(-) diff --git a/crates/app-iced/src/pages/harness.rs b/crates/app-iced/src/pages/harness.rs index 874b8c7..55873f6 100644 --- a/crates/app-iced/src/pages/harness.rs +++ b/crates/app-iced/src/pages/harness.rs @@ -51,6 +51,8 @@ //! //! 等 3 帧再截图与 `app.rs` 同一口径(M60 坑 #5):第一帧可能是启动时的旧尺寸,字体首次上屏会抖动。 +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; use std::time::Instant; use iced::{window, Element, Point, Subscription, Task}; @@ -87,9 +89,13 @@ fn should_request_capture(frames: u32, dispatched: bool, capture_mode: bool) -> /// 采样「派发了却回不来」的宽限帧数(M100 r2 p3-b)。 /// /// 为什么需要它:一次性闸门(上面那条)把「每帧重试」换成了「只试一次」,于是若 -/// `window::screenshot` 的 Task **根本产不出 `Captured`**(窗口消失 / 后端报错),harness -/// 既不再采样也不 `exit()` ⇒ CI 上表现成**超时**,而不是红灯。超时会被当成基础设施抖动重跑 -/// 过去,红才会被人看 —— 所以宁可在超过宽限后**具名红着退出**。 +/// `window::screenshot` 的 Task 回不来,harness 既不再采样也不 `exit()` ⇒ CI 上表现成**超时**, +/// 而不是红灯。超时会被当成基础设施抖动重跑过去,红才会被人看。 +/// +/// ⚠️ **这条兜底只覆盖「还有帧」的情形**(M100 r3 P2-1 订正,我原来在这里写反了): +/// `window::frames()` 只在 `Event::Window(RedrawRequested)` 时产消息,**窗口消失 ⇒ 不再有帧 +/// ⇒ 这里的判据根本求值不到**。那一路由 `run()` 里的**终态不变量**兜住(跑完没图 ⇒ 红着退出); +/// 而后端报错是另一回事:`run()` 直接返回 `Err`,本来就是非 0。 /// /// 30 帧在取证窗口里约 3~4 秒(实测 ~120ms/帧),远大于正常路径「第 3 帧派发 → 第 4~5 帧落盘」。 const CAPTURE_GIVE_UP_FRAMES: u32 = 30; @@ -118,8 +124,11 @@ struct Harness { frames: u32, /// 采样**已派发**(M100 r1 P2-1):置位后不再请求第二张,避免「最后写入者说了算」。 dispatched: bool, - /// 本进程真正落盘了几次(正常必须恒为 1;打进日志让复审查得到)。 - writes: u32, + /// **成功落盘**的次数(共享给 [`run`],终态不变量读它)。 + /// + /// 只在 `shot::save` 返回 `Ok` 之后自增:这是「真的写出去了」,不是「我们打算写」(U1)。 + /// 正常必须恰好 1;打进日志让复审能数。 + written: Arc, /// 本张图收到过几个鼠标事件(`Message::Pointer` 的计数)。 /// /// ⚠️ 它只证明「鼠标消息到没到」,**不**证明「像素会不会变」(复审实测:注入 12 个事件而像素 @@ -201,7 +210,7 @@ impl Harness { // 兜底(M100 r2 p3-b):采样派发了却回不来时,不许挂死也不许 exit 0。 // 这里用 `std::process::exit(1)` 而不是 `iced::exit()`:iced 0.14 没有「带退出码 // 结束」的入口,`exit()` 恒 0 —— 而这条的全部意义就是让 CI 看到**红**而不是超时。 - if capture_stalled(self.frames, self.writes) { + if capture_stalled(self.frames, self.written.load(Ordering::Relaxed)) { eprintln!( "[harness] 采样超时:已过 CAPTURE_AT_FRAME+{CAPTURE_GIVE_UP_FRAMES} 帧\ (当前 {} 帧)仍未收到 Captured,放弃并红着退出", @@ -237,16 +246,20 @@ impl Harness { let Some(path) = self.request.screenshot.clone() else { return Task::none(); }; - self.writes += 1; + // 只在真的写成功时计数(U1):`writes` 若在 save 之前自增,一次落盘失败也会被 + // 记成「有图了」,而 `run()` 的终态不变量正是拿它判「有没有证据」。 match shot::save(&screenshot, &path) { - Ok(summary) => eprintln!("{}", summary.describe()), + Ok(summary) => { + eprintln!("{}", summary.describe()); + self.written.fetch_add(1, Ordering::Relaxed); + } Err(error) => eprintln!("[harness] 截图失败:{error}"), } eprintln!( "[harness] slug={} backend={} 落盘次数={} 指针事件={}", self.request.slug(), self.backend, - self.writes, + self.written.load(Ordering::Relaxed), self.pointer ); // OS 侧密闭指标(M100 r1 P2-2):`WS_EX_TRANSPARENT` 在不在。三态,`unknown` 不许 diff --git a/crates/app-iced/src/pages/harness/app.rs b/crates/app-iced/src/pages/harness/app.rs index 4e33e1d..0076efb 100644 --- a/crates/app-iced/src/pages/harness/app.rs +++ b/crates/app-iced/src/pages/harness/app.rs @@ -4,6 +4,8 @@ //! 参数解析在 [`super::request`],窗口状态机与采样闸门在 [`super`],OS 侧密闭指标在 //! [`super::caps`]。搬出来没有改行为(同一 argv 的 PNG sha256 不变)。 +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; use std::time::Instant; use iced::{window, Point, Task}; @@ -20,6 +22,15 @@ use crate::theme::Ui; use super::{demo_model, Harness, Message, Request, CAPTURE_POSITION}; +/// **终态判据**:取证跑完了却一张图都没落盘 ⇒ 失败。 +/// +/// 单独拎成纯函数是为了让它能被断言(`run()` 要真窗口才能跑)。它守的是 M100 r3 P2-1 那个 +/// 真实缺陷:窗口在第 3 帧消失时,`window::frames()` 不再产消息 ⇒ 帧超时兜底**求值不到**, +/// 而 iced_winit 对「最后一个窗口 Destroyed」直接退出 ⇒ 曾经是 **exit 0 + 没有 PNG**。 +pub(super) fn no_evidence(written: u32) -> bool { + written == 0 +} + /// 启动(阻塞到出图或关窗)。 /// /// **取证通道的硬规则(M97)**:宁可红着退出,也不能悄悄换一种后端继续出图(以前 wgpu 挂掉时 @@ -97,9 +108,15 @@ pub fn run(request: Request) -> Result<(), String> { state }); + // 落盘计数器(终态不变量 + 日志共用):只在 `shot::save` 成功后自增。 + let written = Arc::new(AtomicU32::new(0)); + let application = |backend: renderer::Kind| { let boot = request.clone(); let appearance = appearance.clone(); + // 落盘次数要能被 `run()` 读到(终态不变量用它),所以走共享计数器而不是 `Harness` 的私有字段: + // 值只在 `shot::save` **成功**之后自增,是「真的写出去了」而不是「我们打算写」(U1)。 + let written = Arc::clone(&written); iced::application( move || { let mut model = demo_model(boot.screen, boot.width); @@ -120,7 +137,7 @@ pub fn run(request: Request) -> Result<(), String> { placed: None, frames: 0, dispatched: false, - writes: 0, + written: Arc::clone(&written), pointer: 0, started, first_frame: None, @@ -182,6 +199,21 @@ pub fn run(request: Request) -> Result<(), String> { outcome?; + // **终态不变量(M100 r3 P2-1)**:取证跑完了却一张图都没落盘 = 失败。 + // + // 为什么要有这一条(复审变异实测):兜底原来只有「帧数超时」,而 `window::frames()` 只在 + // `Event::Window(RedrawRequested)` 时产消息 —— 窗口一旦消失就不再 redraw ⇒ `Message::Frame` + // 不再产生 ⇒ `capture_stalled` **永远求值不到**;同时 iced_winit 对「最后一个窗口 Destroyed」 + // 直接 `Control::Exit`,`run()` 返回 `Ok(())`。表现是 **exit 0 + 没有 PNG**,与成功跑同一形状 + // 只少了出图那几行 —— 只看退出码的脚本会读到「成功 + 零证据」。 + // + // 所以判据从「超时」升级成「终态」:**跑完了没有图就是失败**。它比超时强,因为覆盖 + // 「帧不再产生」这一类;帧超时那条保留,管「有帧但采样回不来」。 + if no_evidence(written.load(Ordering::Relaxed)) { + eprintln!("[harness] 取证结束但没有任何图落盘(窗口提前消失 / 落盘失败),按失败退出"); + std::process::exit(1); + } + eprintln!( "[harness] 用时 {:.0} ms", started.elapsed().as_secs_f64() * 1000.0 diff --git a/crates/app-iced/src/pages/harness/tests.rs b/crates/app-iced/src/pages/harness/tests.rs index 6ad90af..fc1c0c5 100644 --- a/crates/app-iced/src/pages/harness/tests.rs +++ b/crates/app-iced/src/pages/harness/tests.rs @@ -221,3 +221,24 @@ fn the_stall_guard_fires_only_after_the_grace_window() { let grace = CAPTURE_GIVE_UP_FRAMES; assert!(grace >= 10, "宽限帧数 {grace} 太小,正常路径会误触发"); } + +/// **终态不变量:跑完了没有图就是失败**(M100 r3 P2-1)。 +/// +/// 抓得住:谁把 `run()` 里那条后置检查删掉/改弱(删掉之后,窗口在第 3 帧消失会让进程 +/// **exit 0 且没有 PNG** —— 复审变异实测就是这个形状,只看退出码的脚本会读到「成功 + 零证据」)。 +/// 抓不住:这条检查**有没有被走到**(窗口消失的时序要真窗口,见报告里的变异复现); +/// 也抓不住「图写到了错误的路径」(那要校验目标文件,属另一件事)。 +#[test] +fn finishing_without_a_single_write_is_a_failure() { + use super::app::no_evidence; + + assert!( + no_evidence(0), + "一张图都没落盘时,取证结束必须判失败(不许 exit 0)" + ); + assert!(!no_evidence(1), "落过盘就算有证据"); + assert!( + !no_evidence(2), + "重复落盘也算有证据(虽然不该发生,见一次性闸门)" + ); +}