From 95530a5311f5cc084b4a9908e9139cbb2a2c2c55 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 3 Nov 2026 16:23:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(app-iced):=20M100=20r2=20p3-b=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E9=87=87=E6=A0=B7=E5=9B=9E=E4=B8=8D=E6=9D=A5?= =?UTF-8?q?=E6=97=B6=E7=BA=A2=E7=9D=80=E9=80=80=E5=87=BA=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=8C=82=E6=AD=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r1 的一次性闸门把「每帧重试」换成了「只试一次」,于是若 `window::screenshot` 的 Task 根本 产不出 `Captured`(窗口消失 / 后端报错),harness 既不再采样也不 `exit()` ⇒ CI 上表现成 **超时**而不是红灯。超时会被当成基础设施抖动重跑过去,红才会被人看。 - 新增 `CAPTURE_GIVE_UP_FRAMES = 30`(实测 ~120ms/帧 ⇒ 约 3~4 秒,远大于正常路径 「第 3 帧派发 → 第 4~5 帧落盘」)。 - 新增纯判据 `capture_stalled(frames, writes) = writes == 0 && frames > CAPTURE_AT_FRAME + N`: 不区分「一直没派发」与「派发了没回来」——两者对使用者的表现都是「没有图」,都该红着退出。 - 触发时打一行具名错误并用 `std::process::exit(1)` 退出:iced 0.14 没有「带退出码结束」的入口 (`iced::exit()` 恒 0),而这条的全部意义就是让 CI 看到红。 实测: - 正常路径 ×5:exit 0、`落盘次数=1`、`passthrough_ext=on`、sha `0d7b4f5b`,`采样超时` 行 0 次。 - 变异(N 改 0):两条新单测红(399 passed / 2 failed),真二进制跑出 `[harness] 采样超时:已过 CAPTURE_AT_FRAME+0 帧(当前 4 帧)仍未收到 Captured,放弃并红着退出` 且 **exit=1**(×3)。改回 30 后单测 401 passed、二进制 exit 0。 --- crates/app-iced/src/pages/harness.rs | 29 ++++++++++++ crates/app-iced/src/pages/harness/tests.rs | 52 +++++++++++++++++++++- 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/crates/app-iced/src/pages/harness.rs b/crates/app-iced/src/pages/harness.rs index 9d1ec69..874b8c7 100644 --- a/crates/app-iced/src/pages/harness.rs +++ b/crates/app-iced/src/pages/harness.rs @@ -84,6 +84,24 @@ fn should_request_capture(frames: u32, dispatched: bool, capture_mode: bool) -> capture_mode && !dispatched && frames >= CAPTURE_AT_FRAME } +/// 采样「派发了却回不来」的宽限帧数(M100 r2 p3-b)。 +/// +/// 为什么需要它:一次性闸门(上面那条)把「每帧重试」换成了「只试一次」,于是若 +/// `window::screenshot` 的 Task **根本产不出 `Captured`**(窗口消失 / 后端报错),harness +/// 既不再采样也不 `exit()` ⇒ CI 上表现成**超时**,而不是红灯。超时会被当成基础设施抖动重跑 +/// 过去,红才会被人看 —— 所以宁可在超过宽限后**具名红着退出**。 +/// +/// 30 帧在取证窗口里约 3~4 秒(实测 ~120ms/帧),远大于正常路径「第 3 帧派发 → 第 4~5 帧落盘」。 +const CAPTURE_GIVE_UP_FRAMES: u32 = 30; + +/// 采样是不是**卡住了**:过了宽限帧数,一次都没落盘。 +/// +/// 不区分「一直没派发」(窗口始终没就绪)与「派发了没回来」——两者对使用者的表现都是 +/// 「没有图」,都该红着退出而不是挂死。 +fn capture_stalled(frames: u32, writes: u32) -> bool { + writes == 0 && frames > CAPTURE_AT_FRAME + CAPTURE_GIVE_UP_FRAMES +} + struct Harness { request: Request, ui: Ui, @@ -180,6 +198,17 @@ impl Harness { self.first_frame = Some(self.started.elapsed()); } self.frames += 1; + // 兜底(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) { + eprintln!( + "[harness] 采样超时:已过 CAPTURE_AT_FRAME+{CAPTURE_GIVE_UP_FRAMES} 帧\ + (当前 {} 帧)仍未收到 Captured,放弃并红着退出", + self.frames + ); + std::process::exit(1); + } // **一次一采样**(M100 r1 P2-1):以前这里是 `frames >= CAPTURE_AT_FRAME`,于是第 3 帧 // 之后**每一帧**都发一次 `window::screenshot`,而 `Captured` 每次都覆盖同一文件才 // `exit()`——已派发的采样不会因为 exit 被撤回 ⇒ **最后写入者说了算**。复审实测 12 次里 diff --git a/crates/app-iced/src/pages/harness/tests.rs b/crates/app-iced/src/pages/harness/tests.rs index d7076d2..6ad90af 100644 --- a/crates/app-iced/src/pages/harness/tests.rs +++ b/crates/app-iced/src/pages/harness/tests.rs @@ -5,7 +5,7 @@ //! 来自 `GWL_EXSTYLE` 的 `WS_EX_TRANSPARENT`);`指针事件=N` 只是辅助指标(它只证明消息到没到)。 use super::request::Request; -use super::should_request_capture; +use super::{capture_stalled, should_request_capture, CAPTURE_AT_FRAME, CAPTURE_GIVE_UP_FRAMES}; use crate::cli::Backend; use crate::pages::Screen; @@ -171,3 +171,53 @@ fn the_passthrough_capability_is_three_states() { "句柄 0 = 还没拿到窗口" ); } + +/// **兜底不许在正常路径误触发**(M100 r2 p3-b)。 +/// +/// 抓得住:宽限帧数被改小到「采样还在飞行中就判死」(例如把它写成 0)——那时第 4 帧 +/// (第 3 帧派发、第 5 帧才落盘)就会误触发,CI 上表现为「正常出图却红」。 +/// 抓不住:真实 `window::screenshot` 卡死的时序(那要注入一个不回的采样,测试替不了)。 +#[test] +fn the_stall_guard_never_fires_on_the_normal_path() { + // 实测时序:第 3 帧派发,第 4~5 帧才收到 Captured(采样在飞行中 writes 还是 0) + let mut first_bad: Option<(u32, bool, u32)> = None; + for frames in 1..=200u32 { + let dispatched = frames >= CAPTURE_AT_FRAME; + let writes = u32::from(dispatched && frames >= CAPTURE_AT_FRAME + 2); + if capture_stalled(frames, writes) { + first_bad.get_or_insert((frames, dispatched, writes)); + } + } + assert!( + first_bad.is_none(), + "正常路径误触发兜底(帧, dispatched, writes)={first_bad:?}" + ); +} + +/// **兜底只在「有帧但没采样」时触发**,且必须过了宽限窗口才触发。 +/// +/// 抓得住:① 判据写成 `frames >= CAPTURE_AT_FRAME`(宽限为 0)——正常路径会立刻误触发; +/// ② 判据漏掉 `writes == 0`(已经落过盘还报超时); +/// ③ 宽限窗口被写成 `>=` 边界错位(差一帧)。 +/// 抓不住:`exit(1)` 到底有没有被操作系统收到(那要跑真二进制,见报告里的变异实测)。 +#[test] +fn the_stall_guard_fires_only_after_the_grace_window() { + let threshold = CAPTURE_AT_FRAME + CAPTURE_GIVE_UP_FRAMES; + assert!( + !capture_stalled(threshold, 0), + "刚好到阈值还不算超时(要给最后一帧留余量)" + ); + assert!( + capture_stalled(threshold + 1, 0), + "越过阈值且一次都没落盘 ⇒ 必须放弃(红着退出,不许挂死)" + ); + assert!( + !capture_stalled(u32::MAX, 1), + "已经落过盘就不算卡住(正常路径由 exit() 结束)" + ); + assert!(!capture_stalled(1, 0), "前几帧本来就没有图,不算卡住"); + // 宽限必须明显大于「派发→落盘」的实测间隔(约 2 帧),否则正常路径会误触发。 + // 用局部绑定而不是直接比较常量:`assert!(CONST >= 10)` 会被 clippy 判成常量断言。 + let grace = CAPTURE_GIVE_UP_FRAMES; + assert!(grace >= 10, "宽限帧数 {grace} 太小,正常路径会误触发"); +}