|
| 1 | +//! 画面停更监测(REQ-281,v0.19.6)——WGC 静默失活 watchdog + 心跳节拍纯逻辑。 |
| 2 | +//! |
| 3 | +//! @ai-context: 用户双屏复现(视频本身在动但画面停更)指向我方 WGC 会话静默 |
| 4 | +//! 失活——WGC 内容驱动出帧,失活不报错、只表现为"无新帧"。 |
| 5 | +//! 本模块把判定做成可单测的纯状态机(时间/帧到达/后端/可见性), |
| 6 | +//! 副作用(emit/重建)由调用方(屏幕 worker)执行。 |
| 7 | +//! |
| 8 | +//! @ai-context: 判停口径与业界一致:只认"最小化/不可见/长时间无帧",不认失焦; |
| 9 | +//! idle 降频探针(5s 一次)与暂停态由调用方 gate(不喂本模块 |
| 10 | +//! allow_stall=false),防静止画面/静音学习段误报误重建。 |
| 11 | +
|
| 12 | +use std::time::{Duration, Instant}; |
| 13 | + |
| 14 | +/// 无新帧持续超过该值视为"疑似停更"(视频正常播放帧率远高于此)。 |
| 15 | +pub const STALL_SECS: u64 = 5; |
| 16 | +/// WGC 重建最小间隔(重建本身有成本;配合 DXGI_RECREATE_INTERVAL=30s 节奏)。 |
| 17 | +pub const RECREATE_MIN_INTERVAL: Duration = Duration::from_secs(30); |
| 18 | +/// 帧心跳事件节拍(帧是否到达/静默秒数/后端/可见性上报前端)。 |
| 19 | +pub const HEARTBEAT_EVERY: Duration = Duration::from_secs(2); |
| 20 | +/// 采样新鲜度窗口:距上次真实采样 > 该值(idle/暂停)不判停更。 |
| 21 | +pub const SAMPLE_FRESH_MS: Duration = Duration::from_millis(1500); |
| 22 | + |
| 23 | +/// 停更监测状态(单采样线程持有,方法均为纯逻辑——时间由调用方注入)。 |
| 24 | +#[derive(Debug, Clone)] |
| 25 | +pub struct FrameLiveness { |
| 26 | + /// 最近一次真实采样的墙钟时刻(判定新鲜度 gate) |
| 27 | + last_sample_at: Option<Instant>, |
| 28 | + /// 无新帧持续起点(有新帧即清 None) |
| 29 | + stall_since: Option<Instant>, |
| 30 | + /// 停更提示已上抛(边沿去重——恢复才清) |
| 31 | + pub stalled: bool, |
| 32 | + /// 上次 WGC 重建时刻(节流) |
| 33 | + last_recreate_at: Option<Instant>, |
| 34 | + /// 上次心跳时刻 |
| 35 | + last_heartbeat_at: Option<Instant>, |
| 36 | +} |
| 37 | + |
| 38 | +/// 一次采样结论(喂入后由调用方读 stall_since/stalled 执行副作用)。 |
| 39 | +impl FrameLiveness { |
| 40 | + pub fn new() -> Self { |
| 41 | + Self { |
| 42 | + last_sample_at: None, |
| 43 | + stall_since: None, |
| 44 | + stalled: false, |
| 45 | + last_recreate_at: None, |
| 46 | + last_heartbeat_at: None, |
| 47 | + } |
| 48 | + } |
| 49 | + |
| 50 | + /// 采样结果入账:got_frame=true → 清停更窗口;false → 续记停更起点。 |
| 51 | + /// |
| 52 | + /// @param now 墙钟(Instant::now) |
| 53 | + pub fn observe(&mut self, now: Instant, got_frame: bool) { |
| 54 | + self.last_sample_at = Some(now); |
| 55 | + if got_frame { |
| 56 | + self.stall_since = None; |
| 57 | + } else { |
| 58 | + self.stall_since = self.stall_since.or(Some(now)); |
| 59 | + } |
| 60 | + } |
| 61 | + |
| 62 | + /// 距上次真实采样是否仍"新鲜"(> SAMPLE_FRESH_MS 的静默不判停更——idle/ |
| 63 | + /// 暂停段由调用方 gate,此处兜底防御)。 |
| 64 | + pub fn fresh(&self, now: Instant) -> bool { |
| 65 | + self.last_sample_at |
| 66 | + .is_some_and(|t| now.duration_since(t) <= SAMPLE_FRESH_MS) |
| 67 | + } |
| 68 | + |
| 69 | + /// 当前停更秒数(未开始停更/不新鲜 → None)。 |
| 70 | + pub fn stall_secs(&self, now: Instant) -> Option<u64> { |
| 71 | + if !self.fresh(now) { |
| 72 | + return None; |
| 73 | + } |
| 74 | + let since = self.stall_since?; |
| 75 | + let secs = now.duration_since(since).as_secs(); |
| 76 | + (secs >= 1).then_some(secs) |
| 77 | + } |
| 78 | + |
| 79 | + /// 停更提示边沿:持续 ≥ STALL_SECS 且尚未上抛。 |
| 80 | + pub fn stall_edge(&self, now: Instant) -> bool { |
| 81 | + self.stall_secs(now).is_some_and(|s| s >= STALL_SECS) && !self.stalled |
| 82 | + } |
| 83 | + |
| 84 | + /// 恢复边沿:本采样有新帧且此前处于停更提示态。 |
| 85 | + pub fn recover_edge(&self, got_frame: bool) -> bool { |
| 86 | + got_frame && self.stalled |
| 87 | + } |
| 88 | + |
| 89 | + /// WGC 重建是否到期(停更 ≥ STALL_SECS 且距上次重建 ≥ 最小间隔)。 |
| 90 | + pub fn recreate_due(&self, now: Instant) -> bool { |
| 91 | + self.stall_secs(now).is_some_and(|s| s >= STALL_SECS) |
| 92 | + && self |
| 93 | + .last_recreate_at |
| 94 | + .is_none_or(|t| now.duration_since(t) >= RECREATE_MIN_INTERVAL) |
| 95 | + } |
| 96 | + |
| 97 | + /// 心跳是否到期。 |
| 98 | + pub fn heartbeat_due(&self, now: Instant) -> bool { |
| 99 | + self.last_heartbeat_at |
| 100 | + .is_none_or(|t| now.duration_since(t) >= HEARTBEAT_EVERY) |
| 101 | + } |
| 102 | + |
| 103 | + /// 停更提示已上抛 / 心跳已发 / 重建已记(调用方执行副作用后调)。 |
| 104 | + pub fn mark_stalled(&mut self) { |
| 105 | + self.stalled = true; |
| 106 | + } |
| 107 | + |
| 108 | + pub fn clear_stalled(&mut self) { |
| 109 | + self.stalled = false; |
| 110 | + self.stall_since = None; |
| 111 | + } |
| 112 | + |
| 113 | + pub fn mark_heartbeat(&mut self, now: Instant) { |
| 114 | + self.last_heartbeat_at = Some(now); |
| 115 | + } |
| 116 | + |
| 117 | + pub fn mark_recreate(&mut self, now: Instant) { |
| 118 | + self.last_recreate_at = Some(now); |
| 119 | + } |
| 120 | +} |
| 121 | + |
| 122 | +#[cfg(test)] |
| 123 | +mod tests { |
| 124 | + use super::*; |
| 125 | + |
| 126 | + fn t0() -> Instant { |
| 127 | + Instant::now() |
| 128 | + } |
| 129 | + |
| 130 | + #[test] |
| 131 | + fn fresh_samples_clear_stall_and_recover_edges() { |
| 132 | + let now = t0(); |
| 133 | + let mut lv = FrameLiveness::new(); |
| 134 | + // 连续无帧 → 5s 进入停更边沿;恢复新帧 → recover 边沿 + 停更清零 |
| 135 | + lv.observe(now, false); |
| 136 | + lv.observe(now + Duration::from_millis(500), false); |
| 137 | + let stalled_at = now + Duration::from_secs(6); |
| 138 | + assert!(lv.stall_edge(stalled_at), "≥5s 无帧应触发停更边沿"); |
| 139 | + assert_eq!(lv.stall_secs(stalled_at), Some(6)); |
| 140 | + lv.mark_stalled(); |
| 141 | + // 同刻不再重复边沿(去重) |
| 142 | + assert!(!lv.stall_edge(stalled_at)); |
| 143 | + // 有新帧 → 恢复 |
| 144 | + let rec_at = stalled_at + Duration::from_secs(2); |
| 145 | + assert!(lv.recover_edge(true)); |
| 146 | + lv.observe(rec_at, true); |
| 147 | + lv.clear_stalled(); |
| 148 | + assert_eq!(lv.stall_secs(rec_at + Duration::from_secs(1)), None); |
| 149 | + } |
| 150 | + |
| 151 | + #[test] |
| 152 | + fn stale_samples_do_not_judge_stall() { |
| 153 | + let now = t0(); |
| 154 | + let mut lv = FrameLiveness::new(); |
| 155 | + lv.observe(now, false); |
| 156 | + // 距上次采样超过新鲜窗口 → 不判停更(idle 探针兜底防御) |
| 157 | + let later = now + Duration::from_secs(10); |
| 158 | + assert_eq!(lv.stall_secs(later), None); |
| 159 | + assert!(!lv.stall_edge(later)); |
| 160 | + } |
| 161 | + |
| 162 | + #[test] |
| 163 | + fn recreate_throttled_by_min_interval() { |
| 164 | + let now = t0(); |
| 165 | + let mut lv = FrameLiveness::new(); |
| 166 | + lv.observe(now, false); |
| 167 | + lv.observe(now + Duration::from_secs(1), false); |
| 168 | + let due = now + Duration::from_secs(6); |
| 169 | + assert!(lv.recreate_due(due)); |
| 170 | + lv.mark_recreate(due); |
| 171 | + assert!(!lv.recreate_due(due + Duration::from_secs(10)), "30s 节流内不重复重建"); |
| 172 | + assert!(lv.recreate_due(due + RECREATE_MIN_INTERVAL + Duration::from_secs(1))); |
| 173 | + } |
| 174 | + |
| 175 | + #[test] |
| 176 | + fn heartbeat_interval() { |
| 177 | + let now = t0(); |
| 178 | + let mut lv = FrameLiveness::new(); |
| 179 | + assert!(lv.heartbeat_due(now), "首拍即到期"); |
| 180 | + lv.mark_heartbeat(now); |
| 181 | + assert!(!lv.heartbeat_due(now + Duration::from_secs(1))); |
| 182 | + assert!(lv.heartbeat_due(now + HEARTBEAT_EVERY + Duration::from_millis(50))); |
| 183 | + } |
| 184 | +} |
0 commit comments