fix(app-iced): M100 r3 P2-1 —— 判据从「帧超时」升级成「终态无证据即失败」

复审在 `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<AtomicU32>`,**只在 `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()` 里那条
  检查不会让它红——它守的是判据本身,不是调用点,报告里写清了「抓不住」)。
This commit is contained in:
AskaEth committed 2026-11-03 17:34:14 +08:00
1 parent 95530a5311
commit d298691c17
3 files changed
+76 -10

No files matched your search

+22 -9
View File
@@ -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<AtomicU32>,
/// 本张图收到过几个鼠标事件(`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` 不许