Skip to content

Commit fa1647a

Browse files
committed
fix(capture): auto_paused 泄漏/暂停期 watchdog/OCR 去重/误停阈值(2026-09-05 审查批A)
1 parent e59c413 commit fa1647a

3 files changed

Lines changed: 79 additions & 25 deletions

File tree

‎app/src-tauri/src/live_session_frame.rs‎

Lines changed: 63 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,30 @@ fn media_sound_recent(slot: &Arc<Mutex<Option<Instant>>>) -> bool {
136136
last.is_some_and(|t| t.elapsed().as_millis() as u64 <= crate::media_state::SOUND_RECENT_MS)
137137
}
138138

139+
/// 审查 F3:自动暂停期 watchdog 探针(无提示无心跳——暂停语义下停更提示无
140+
/// 意义;仅做观测 + WGC 自愈)。防复合卡死:暂停期间 WGC 会话失活(REQ-281
141+
/// 原场景)→ 恢复检测读不到新帧 → 永久卡自动暂停;此处探针周期性复活会话。
142+
fn watchdog_paused_probe(
143+
mut screen: Option<&mut ScreenCaptureSampler>,
144+
liveness: &mut crate::frame_liveness::FrameLiveness,
145+
got_frame: bool,
146+
now: Instant,
147+
) {
148+
let Some(_) = screen.as_mut() else { return };
149+
liveness.observe(now, got_frame);
150+
let wgc = screen.as_ref().is_some_and(|s| s.backend_name() == "wgc");
151+
let visible = screen.as_ref().is_none_or(|s| s.target_visible());
152+
if !wgc || !visible || got_frame {
153+
return;
154+
}
155+
if liveness.recreate_due(now) {
156+
if let Some(s) = screen.as_mut() {
157+
s.revive_wgc();
158+
}
159+
liveness.mark_recreate(now);
160+
}
161+
}
162+
139163
/// 屏幕采样线程入口(TD-026 修复:OCR 从会话线程移出,音频消费不再被阻塞)。
140164
///
141165
/// @ai-context: ScreenCaptureSampler 持 COM 对象(非 Send),在本线程内创建与使用,
@@ -318,6 +342,14 @@ pub fn run_screen_worker(
318342
&mut last_capture_error,
319343
&mut got_frame,
320344
);
345+
// 审查 F3:暂停期 watchdog 探针(无提示)——WGC 会话失活时
346+
// 恢复检测永远读不到新帧 → 自动暂停永久卡死;此处定期自愈
347+
watchdog_paused_probe(
348+
screen.as_mut(),
349+
&mut liveness,
350+
got_frame,
351+
Instant::now(),
352+
);
321353
// REQ-291 快恢复(主通道):声画任一恢复 ≤~1.5s 解除自动暂停
322354
// (OCR 5s 判定降级为辅助——保留其后兜底)
323355
if last_media_tick.elapsed() >= Duration::from_secs(1) {
@@ -405,21 +437,33 @@ pub fn run_screen_worker(
405437
last_media_tick.duration_since(t) <= Duration::from_millis(1500)
406438
});
407439
let decision = media_detector.tick(sound_recent, motion_recent);
408-
if decision == crate::media_state::MediaDecision::Suspend {
409-
let ms = comp_epoch.elapsed().as_millis() as u64;
410-
pause.paused.store(true, Ordering::SeqCst);
411-
auto_paused = true;
412-
crate::player_behavior::record_action(
413-
&crate::player_behavior::PlayerAction {
414-
kind: crate::player_behavior::PlayerActionKind::Pause,
415-
value: None,
416-
},
417-
ms,
418-
session_id,
419-
&db,
420-
);
421-
let _ = app.emit("live:media-paused", ());
422-
eprintln!("[ScreenWorker] 随播随停:声画双通道确认视频暂停 → 自动暂停捕获");
440+
match decision {
441+
crate::media_state::MediaDecision::Suspend => {
442+
let ms = comp_epoch.elapsed().as_millis() as u64;
443+
pause.paused.store(true, Ordering::SeqCst);
444+
auto_paused = true;
445+
crate::player_behavior::record_action(
446+
&crate::player_behavior::PlayerAction {
447+
kind: crate::player_behavior::PlayerActionKind::Pause,
448+
value: None,
449+
},
450+
ms,
451+
session_id,
452+
&db,
453+
);
454+
let _ = app.emit("live:media-paused", ());
455+
eprintln!("[ScreenWorker] 随播随停:声画双通道确认视频暂停 → 自动暂停捕获");
456+
}
457+
// 审查 F1:手动恢复(pause=false)后媒体相位为 Paused 的主路径拍——
458+
// 声画恢复产出 Resume,必须同步清 auto_paused(否则后续手动暂停
459+
// 被误当自动暂停走轻量轮询并在 1-2s 内被声通道自动解除)
460+
crate::media_state::MediaDecision::Resume => {
461+
if auto_paused {
462+
auto_paused = false;
463+
eprintln!("[ScreenWorker] 随播随停:主路径恢复 → 清除自动暂停标记");
464+
}
465+
}
466+
crate::media_state::MediaDecision::None => {}
423467
}
424468
}
425469
// M4:每 2s 采样 CPU 负载(降级标志变化打印——静默失败可见化)
@@ -743,9 +787,11 @@ pub fn run_screen_worker(
743787
session_id,
744788
&db,
745789
);
746-
if paused {
790+
if paused && !pause.paused.load(Ordering::SeqCst) {
747791
// P2:检测到视频暂停 → 自动暂停捕获(共享标志;
748-
// 下一轮循环进入轻量轮询,恢复检测不中断)
792+
// 下一轮循环进入轻量轮询,恢复检测不中断)。
793+
// 审查 F5:已由媒体通道暂停(pause=true)时不重复
794+
// 记账/发事件(同迭代双系统重复 Pause)
749795
pause.paused.store(true, Ordering::SeqCst);
750796
auto_paused = true;
751797
let _ = app.emit("live:media-paused", ());

‎app/src-tauri/src/media_state.rs‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@
1212
//! @ai-context: 判定粒度=屏幕采样拍(1s);音频槽只记"最后有声时刻"。标称
1313
//! 停检 ~2 拍、恢复 ≤~1.5s,阈值与滞回一次真机 A/B 标定(验收项)。
1414
15-
/// 疑似暂停所需连续"无声且无画面动"拍数(采样拍≈1s)。
16-
pub const SUSPECT_AFTER_TICKS: u32 = 1;
17-
/// 确认暂停所需连续拍数(suspect 后再一拍无证即确认——总 ~2s)。
15+
/// 疑似暂停所需连续"无声且无画面动"拍数(采样拍≈1s;审查 F4:1 拍过锐,
16+
/// 静音+静止的真实讲解停顿 2-4s 即误停——标定前置为 2,总确认 ~3-4s)。
17+
pub const SUSPECT_AFTER_TICKS: u32 = 2;
18+
/// 确认暂停所需连续拍数(suspect 后一拍无证即确认)。
1819
pub const PAUSE_CONFIRM_TICKS: u32 = 1;
1920
/// "有任何声音"的 RMS 阈值(区别于 VAD 语音阈值 0.005——音乐/环境声也算;
2021
/// 需真机标定,验收项)。
@@ -59,7 +60,7 @@ impl MediaDetector {
5960
///
6061
/// 规则(与设计 §2.10 一致):
6162
/// - Playing:有声或画面动 → 计数清零;无声且无动 → silent_ticks++,
62-
/// ≥ SUSPECT_AFTER_TICKS 进入 Suspect;静音但有画面动=无音轨视频,
63+
/// ≥ SUSPECT_AFTER_TICKS(2) 进入 Suspect;静音但有画面动=无音轨视频,
6364
/// 计数清零不升(撤销路径同样在 Suspect 态生效)。
6465
/// - Suspect:声或画任一恢复 → 回 Playing(撤销);仍双静默且
6566
/// ≥ PAUSE_CONFIRM_TICKS → Paused(产出 Suspend)。
@@ -134,9 +135,10 @@ mod tests {
134135
}
135136

136137
#[test]
137-
fn silent_still_two_ticks_confirms_suspend() {
138+
fn silent_still_three_ticks_confirms_suspend() {
138139
let mut m = MediaDetector::new();
139-
// 拍 1:无声无动 → suspect;拍 2:仍无声无动 → Suspend
140+
// 拍 1-2:无声无动 → suspect(第 2 拍);拍 3:仍无声无动 → Suspend
141+
assert_eq!(m.tick(false, false), MediaDecision::None);
140142
assert_eq!(m.tick(false, false), MediaDecision::None);
141143
assert_eq!(m.phase, MediaPhase::Suspect);
142144
assert_eq!(m.tick(false, false), MediaDecision::Suspend);
@@ -146,11 +148,14 @@ mod tests {
146148
#[test]
147149
fn suspect_revoked_by_brief_motion_then_reconfirms() {
148150
let mut m = MediaDetector::new();
151+
assert_eq!(m.tick(false, false), MediaDecision::None);
149152
assert_eq!(m.tick(false, false), MediaDecision::None); // suspect
153+
assert_eq!(m.phase, MediaPhase::Suspect);
150154
// 缓冲动画一拍 → 撤销回 playing
151155
assert_eq!(m.tick(false, true), MediaDecision::None);
152156
assert_eq!(m.phase, MediaPhase::Playing);
153-
// 再两拍静默 → 正常确认暂停
157+
// 再三段静默 → 正常确认暂停
158+
assert_eq!(m.tick(false, false), MediaDecision::None);
154159
assert_eq!(m.tick(false, false), MediaDecision::None);
155160
assert_eq!(m.tick(false, false), MediaDecision::Suspend);
156161
}
@@ -159,12 +164,14 @@ mod tests {
159164
fn paused_resumes_on_either_channel() {
160165
let mut m = MediaDetector::new();
161166
assert_eq!(m.tick(false, false), MediaDecision::None);
167+
assert_eq!(m.tick(false, false), MediaDecision::None);
162168
assert_eq!(m.tick(false, false), MediaDecision::Suspend);
163169
// 画面恢复(用户点播放瞬间帧到)
164170
assert_eq!(m.tick(false, true), MediaDecision::Resume);
165171
assert_eq!(m.phase, MediaPhase::Playing);
166172
// 再停再走,声音恢复路径
167173
assert_eq!(m.tick(false, false), MediaDecision::None);
174+
assert_eq!(m.tick(false, false), MediaDecision::None);
168175
assert_eq!(m.tick(false, false), MediaDecision::Suspend);
169176
assert_eq!(m.tick(true, false), MediaDecision::Resume);
170177
}

‎app/src/pages/ClassroomPage.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,8 @@ export default function ClassroomPage({ onOpenSessions }: { onOpenSessions?: (se
201201
}),
202202
// 2026-08 A1:暂停/恢复事件(硬暂停状态驱动按钮组与徽标)
203203
listen("live:paused", () => setLivePaused(true)),
204-
listen("live:resumed", () => setLivePaused(false)),
204+
// 审查(A 区 F5 相关):手动/自动恢复事件一并清除媒体暂停徽标——防残留
205+
listen("live:resumed", () => { setLivePaused(false); setMediaPaused(false); }),
205206
// 模型自动下载进度(ADR-003)
206207
listen<DownloadProgress>("model:download-progress", (e) => setModelProgress(e.payload)),
207208
listen<boolean>("model:download-done", () => {

0 commit comments

Comments
 (0)