Skip to content

Commit 40ef959

Browse files
committed
refactor(rust): 拆 live_session_frame 第 1 步 — 停更监测
1 parent e5684e8 commit 40ef959

3 files changed

Lines changed: 134 additions & 115 deletions

File tree

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

Lines changed: 5 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ use crate::live_frame_process::{
2424
};
2525
use crate::subtitle_ocr::SubtitleVoter;
2626

27+
use live_session_liveness::{liveness_check, watchdog_paused_probe};
28+
2729
/// 采样节拍(ms):与音频消费解耦,固定 1s 一拍(审查 M5 修复)。
2830
const SAMPLE_TICK_MS: u64 = 1000;
2931
/// 采样线程轮询休眠(ms)——空转粒度,影响停止响应延迟。
@@ -41,93 +43,9 @@ pub struct LatestCapturedFrame {
4143
pub height: u32,
4244
}
4345

44-
// ── REQ-281(v0.19.6):帧心跳 / 停更提示事件载荷 ──
45-
46-
/// live:frame-heartbeat 载荷(诊断观测:后端/帧到达/静默秒数/目标可见性)。
47-
#[derive(serde::Serialize, Clone)]
48-
#[serde(rename_all = "camelCase")]
49-
struct FrameHeartbeatPayload {
50-
backend: String,
51-
got_frame: bool,
52-
silent_secs: u64,
53-
visible: bool,
54-
}
55-
56-
/// live:frame-stalled 载荷(停更秒数——前端提示语)。
57-
#[derive(serde::Serialize, Clone)]
58-
#[serde(rename_all = "camelCase")]
59-
struct FrameStalledPayload {
60-
silent_secs: u64,
61-
}
62-
63-
/// REQ-281 停更监测副作用执行点(真实采样拍后调用一次):
64-
///
65-
/// @ai-context: 判定语义(frame_liveness 纯状态机):仅 WGC 窗口捕获模式判停
66-
/// (DXGI 无帧=桌面无变化属正常,不重建不提示);目标不可见时
67-
/// 清提示不动作(最小化/遮挡另有既有 window-lost/用户感知路径)。
68-
/// 心跳每 2s 一报(含后端名/帧到达/静默秒数/可见性——真机复现
69-
/// 诊断与停更提示共用同一信号源)。
70-
#[allow(clippy::too_many_arguments)]
71-
fn liveness_check(
72-
app: &tauri::AppHandle,
73-
mut screen: Option<&mut ScreenCaptureSampler>,
74-
liveness: &mut crate::frame_liveness::FrameLiveness,
75-
got_frame: bool,
76-
now: Instant,
77-
) {
78-
let backend = screen
79-
.as_ref()
80-
.map(|s| s.backend_name().to_string())
81-
.unwrap_or_else(|| "none".to_string());
82-
let visible = screen.as_ref().is_none_or(|s| s.target_visible());
83-
let silent_secs = liveness.stall_secs(now).unwrap_or(0);
84-
85-
// 心跳(独立于停更判定——诊断观测恒可用)
86-
if liveness.heartbeat_due(now) {
87-
liveness.mark_heartbeat(now);
88-
let _ = app.emit(
89-
"live:frame-heartbeat",
90-
FrameHeartbeatPayload {
91-
backend: backend.clone(),
92-
got_frame,
93-
silent_secs,
94-
visible,
95-
},
96-
);
97-
}
98-
99-
// 非 WGC/无窗口:无"停更"语义(DXGI 超时=桌面无变化);残留提示清掉
100-
if backend != "wgc" || !visible {
101-
if liveness.stalled {
102-
liveness.clear_stalled();
103-
let _ = app.emit("live:frame-recovered", ());
104-
}
105-
return;
106-
}
107-
108-
liveness.observe(now, got_frame);
109-
if liveness.recover_edge(got_frame) {
110-
liveness.clear_stalled();
111-
let _ = app.emit("live:frame-recovered", ());
112-
return;
113-
}
114-
if liveness.stall_edge(now) {
115-
liveness.mark_stalled();
116-
let _ = app.emit(
117-
"live:frame-stalled",
118-
FrameStalledPayload {
119-
silent_secs: liveness.stall_secs(now).unwrap_or(0),
120-
},
121-
);
122-
}
123-
// 停更且到重建节流窗口 → WGC 会话自愈(用户复现=视频在动画面停更)
124-
if !got_frame && liveness.recreate_due(now) {
125-
if let Some(s) = screen.as_mut() {
126-
s.revive_wgc();
127-
}
128-
liveness.mark_recreate(now);
129-
}
130-
}
46+
/// REQ-281 停更监测(停更判定/心跳载荷/WGC 自愈;本文件 ≤300 行,AGENTS.md §3)。
47+
#[path = "live_session_liveness.rs"]
48+
pub(super) mod live_session_liveness;
13149

13250
/// REQ-291:媒体级"最近有声"判定(窗口=SOUND_RECENT_MS ≥ 采样拍 1s——
13351
/// 保证一拍内出现的声音必被读到;锁中毒按无声处理,零阻断)。
@@ -149,32 +67,6 @@ fn light_poll_enabled(cond: crate::pause_state::PauseConditions) -> bool {
14967
!cond.manual && (cond.media || cond.foreground)
15068
}
15169

152-
/// 审查 F3:自动暂停期 watchdog 探针(无提示无心跳——暂停语义下停更提示无
153-
/// 意义;仅做观测 + WGC 自愈)。防复合卡死:暂停期间 WGC 会话失活(REQ-281
154-
/// 原场景)→ 恢复检测读不到新帧 → 永久卡自动暂停;此处探针周期性复活会话。
155-
/// P2-4:轻量轮询扩到 fg 暂停期后探针随之覆盖 fg 期(视频暂停/画面停更期间
156-
/// 同样需要 WGC 自愈;探针只观测 + 复活,任意暂停期无提示副作用)。
157-
fn watchdog_paused_probe(
158-
mut screen: Option<&mut ScreenCaptureSampler>,
159-
liveness: &mut crate::frame_liveness::FrameLiveness,
160-
got_frame: bool,
161-
now: Instant,
162-
) {
163-
let Some(_) = screen.as_mut() else { return };
164-
liveness.observe(now, got_frame);
165-
let wgc = screen.as_ref().is_some_and(|s| s.backend_name() == "wgc");
166-
let visible = screen.as_ref().is_none_or(|s| s.target_visible());
167-
if !wgc || !visible || got_frame {
168-
return;
169-
}
170-
if liveness.recreate_due(now) {
171-
if let Some(s) = screen.as_mut() {
172-
s.revive_wgc();
173-
}
174-
liveness.mark_recreate(now);
175-
}
176-
}
177-
17870
/// 屏幕采样线程入口(TD-026 修复:OCR 从会话线程移出,音频消费不再被阻塞)。
17971
///
18072
/// @ai-context: ScreenCaptureSampler 持 COM 对象(非 Send),在本线程内创建与使用,
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
//! REQ-281 帧停更监测(live_session_frame.rs 的拆分子模块)。
2+
//!
3+
//! @ai-context: 屏幕采样线程的「停更判定 + 心跳 + WGC 自愈」副作用集中于此——
4+
//! 判定语义由 frame_liveness 纯状态机持有,本模块只负责读采样器
5+
//! 后端/可见性、发 live:frame-* 事件、在节流窗口内 revive_wgc。
6+
//! @ai-context: 边界 —— 主采样拍每拍调用 `liveness_check` 一次(含心跳);
7+
//! 自动暂停期的轻量轮询调用 `watchdog_paused_probe`(**无提示无心跳**:
8+
//! 暂停语义下停更提示无意义,仅观测 + 复活,防复合卡死)。
9+
//! @ai-context: 副作用 = app.emit ×3 种事件 + ScreenCaptureSampler::revive_wgc;
10+
//! 无锁新增(仅读屏幕句柄状态);时间基准 now 由调用方注入。
11+
12+
use std::time::Instant;
13+
14+
use tauri::Emitter;
15+
16+
use crate::capture::ScreenCaptureSampler;
17+
18+
/// live:frame-heartbeat 载荷(诊断观测:后端/帧到达/静默秒数/目标可见性)。
19+
#[derive(serde::Serialize, Clone)]
20+
#[serde(rename_all = "camelCase")]
21+
struct FrameHeartbeatPayload {
22+
backend: String,
23+
got_frame: bool,
24+
silent_secs: u64,
25+
visible: bool,
26+
}
27+
28+
/// live:frame-stalled 载荷(停更秒数——前端提示语)。
29+
#[derive(serde::Serialize, Clone)]
30+
#[serde(rename_all = "camelCase")]
31+
struct FrameStalledPayload {
32+
silent_secs: u64,
33+
}
34+
35+
/// REQ-281 停更监测副作用执行点(真实采样拍后调用一次):
36+
///
37+
/// @ai-context: 判定语义(frame_liveness 纯状态机):仅 WGC 窗口捕获模式判停
38+
/// (DXGI 无帧=桌面无变化属正常,不重建不提示);目标不可见时
39+
/// 清提示不动作(最小化/遮挡另有既有 window-lost/用户感知路径)。
40+
/// 心跳每 2s 一报(含后端名/帧到达/静默秒数/可见性——真机复现
41+
/// 诊断与停更提示共用同一信号源)。
42+
#[allow(clippy::too_many_arguments)]
43+
pub(super) fn liveness_check(
44+
app: &tauri::AppHandle,
45+
mut screen: Option<&mut ScreenCaptureSampler>,
46+
liveness: &mut crate::frame_liveness::FrameLiveness,
47+
got_frame: bool,
48+
now: Instant,
49+
) {
50+
let backend = screen
51+
.as_ref()
52+
.map(|s| s.backend_name().to_string())
53+
.unwrap_or_else(|| "none".to_string());
54+
let visible = screen.as_ref().is_none_or(|s| s.target_visible());
55+
let silent_secs = liveness.stall_secs(now).unwrap_or(0);
56+
57+
// 心跳(独立于停更判定——诊断观测恒可用)
58+
if liveness.heartbeat_due(now) {
59+
liveness.mark_heartbeat(now);
60+
let _ = app.emit(
61+
"live:frame-heartbeat",
62+
FrameHeartbeatPayload {
63+
backend: backend.clone(),
64+
got_frame,
65+
silent_secs,
66+
visible,
67+
},
68+
);
69+
}
70+
71+
// 非 WGC/无窗口:无"停更"语义(DXGI 超时=桌面无变化);残留提示清掉
72+
if backend != "wgc" || !visible {
73+
if liveness.stalled {
74+
liveness.clear_stalled();
75+
let _ = app.emit("live:frame-recovered", ());
76+
}
77+
return;
78+
}
79+
80+
liveness.observe(now, got_frame);
81+
if liveness.recover_edge(got_frame) {
82+
liveness.clear_stalled();
83+
let _ = app.emit("live:frame-recovered", ());
84+
return;
85+
}
86+
if liveness.stall_edge(now) {
87+
liveness.mark_stalled();
88+
let _ = app.emit(
89+
"live:frame-stalled",
90+
FrameStalledPayload {
91+
silent_secs: liveness.stall_secs(now).unwrap_or(0),
92+
},
93+
);
94+
}
95+
// 停更且到重建节流窗口 → WGC 会话自愈(用户复现=视频在动画面停更)
96+
if !got_frame && liveness.recreate_due(now) {
97+
if let Some(s) = screen.as_mut() {
98+
s.revive_wgc();
99+
}
100+
liveness.mark_recreate(now);
101+
}
102+
}
103+
104+
/// 审查 F3:自动暂停期 watchdog 探针(无提示无心跳——暂停语义下停更提示无
105+
/// 意义;仅做观测 + WGC 自愈)。防复合卡死:暂停期间 WGC 会话失活(REQ-281
106+
/// 原场景)→ 恢复检测读不到新帧 → 永久卡自动暂停;此处探针周期性复活会话。
107+
/// P2-4:轻量轮询扩到 fg 暂停期后探针随之覆盖 fg 期(视频暂停/画面停更期间
108+
/// 同样需要 WGC 自愈;探针只观测 + 复活,任意暂停期无提示副作用)。
109+
pub(super) fn watchdog_paused_probe(
110+
mut screen: Option<&mut ScreenCaptureSampler>,
111+
liveness: &mut crate::frame_liveness::FrameLiveness,
112+
got_frame: bool,
113+
now: Instant,
114+
) {
115+
let Some(_) = screen.as_mut() else { return };
116+
liveness.observe(now, got_frame);
117+
let wgc = screen.as_ref().is_some_and(|s| s.backend_name() == "wgc");
118+
let visible = screen.as_ref().is_none_or(|s| s.target_visible());
119+
if !wgc || !visible || got_frame {
120+
return;
121+
}
122+
if liveness.recreate_due(now) {
123+
if let Some(s) = screen.as_mut() {
124+
s.revive_wgc();
125+
}
126+
liveness.mark_recreate(now);
127+
}
128+
}

‎docs/standards/line-limit-exemptions.md‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@
3232
|---|---|---|---|
3333
| app/src-tauri/src/commands.rs | 597 | 命令装配域(AppState + 通用命令 + 导入管线编排);登记值 466 过期快照——2026-09-09 实测纠偏(含批 7 delete_note 结果契约 +12;600 硬限内压线) | 若再增长:导入管线命令拆至 commands_import.rs(既有登记计划) |
3434
| app/src/pages/ChatPage.tsx | 593 | AI 对话页编排;批 1(REQ-306/307)active 门控/终态订阅接线净增——2026-09-09 实测纠偏(登记值 529 过期) | 若再增长:任务工具条与发起流拆至 ChatTasksToolbar.tsx |
35-
| app/src/components/NoteListView.tsx | 582 | 超硬限(>600 行),不允许豁免 —— v0.20.12 批 7 接线 + 审查修复轮 4(refreshToken 组序重拉)净增(646→654)——**超 600 硬限随 TD-2026-09-09-A 登记(值刷新)** | **超硬限必须拆**:拖拽/移动接线拆至 useNoteOrders.ts(既有登记计划兑现) |
3635
| app/src-tauri/src/lib.rs | 577 | crate 根 321 `mod` + 16 `#[cfg]` = **337 行地板**;注册清单已移至 `app_commands.rs`(**数据文件**,同属 300–600 豁免带)—— 结构性下界,非欠账 | 已完成(批 0-C3 Task 1,2026-09-11);余下 161 行模块理由注释 + 装配逻辑,无进一步拆分标的 |
3736
| app/src-tauri/src/db_migrations.rs | 573 | v0.20.11 批 6(REQ-315)再增:note_groups.pin ensure_column + note_group_orders 建表(+21,实测 573——登记值 492 过期;schema 单点收敛理由同左) | 若再增长:kb_* 与 chat_* 表 DDL 拆至 db_migrations_kb.rs |
3837
| app/src/types/knowledge.ts | 559 | 知识体系类型域(体系/节点/概念/模型/引用/审计/决策 + v0.13.8 画布契约 + v0.14.1 画布偏好枚举与下拉文案常量)——类型与文案常量同域防漂移(前端类型域拆分任务待执行) | 若再增长:画布偏好类型与文案拆至 types/canvas.ts |
@@ -61,6 +60,7 @@
6160
| app/src/components/KnowledgeCanvasView.tsx | 466 | v0.13.8 画布主视图(RF 装配/拖拽防抖保存/视口持久化/自动排列);v0.13.9 根卡 + 接线方向动态化;v0.14.1 布局/连线下拉 + 偏好读写(+72 行)+ 审查修复(布局 effect 原子化拆分建边 effect/prefsLoaded 控件门控/hasCore 统一 +31 行)——RF 状态与持久化编排内聚(元素构建已拆至 canvasElements/layout* 纯函数) | 若再增长:偏好读写与下拉拆至 useCanvasPrefs.ts;位置持久化拆至 useCanvasPositions.ts |
6261
| app/src-tauri/src/streaming_asr_tests.rs | 462 | 流式 ASR 测试域(端点处理/静音判定/段切分回归)单模块 #[path] 挂载 | 若再增长:端点处理组拆至 streaming_endpoint_tests.rs |
6362
| app/src-tauri/src/structure_note_tests.rs | 458 | v0.7.6(REQ-177~181):结构渲染层单测域(章节插入位置/命名窗口/词汇表排序上限锚点/零回归护栏/JSON 往返)单模块 #[path] 挂载 | 若再增长:词汇表组拆至 structure_note_glossary_tests.rs |
63+
| app/src/components/NoteListView.tsx | 450 | 超硬限(>600 行),不允许豁免 —— v0.20.12 批 7 接线 + 审查修复轮 4(refreshToken 组序重拉)净增(646→654)——**超 600 硬限随 TD-2026-09-09-A 登记(值刷新)** | **超硬限必须拆**:拖拽/移动接线拆至 useNoteOrders.ts(既有登记计划兑现) |
6464
| app/src-tauri/src/db_note_group_clean_tests.rs | 447 | REQ-316(批 7)测试域:判定表(自动/系列/手动/改判/五类残留/影响面外/级联卫生)+ 写路径集成 16 例,单模块 #[path] 挂载 | 若再增长:写路径集成组拆至 db_note_group_clean_flow_tests.rs |
6565
| app/src-tauri/src/commands_session.rs | 443 | v0.6.0 M6 + v0.7.6 审查硬拆后回归:会话命令域(CRUD/质量报告/课程分组/段搜索)内聚;笔记转换管线已拆至 commands_session_note.rs;M2 修复(search_ocr_blocks 传 data_dir 参数) | 若再增长:course/search 拆至 commands_session_extra.rs |
6666
| app/src-tauri/src/symbol_normalize.rs | 443 | v0.6.0 M1(REQ-060):口语符号映射域(映射表/上下文守卫/中文数字解析)内聚;数字解析与守卫共享字符判定 | 若再增长:parse_chinese_number/replace_number_runs 拆至 symbol_numbers.rs |
@@ -126,7 +126,6 @@
126126
| app/src-tauri/src/image_store.rs | 330 | 会话目录本地存图(关键图/参考图集/缩略图走廊三级):(自动摘取,待细化) | 若再增长:按职责拆分 |
127127
| app/src-tauri/src/subtitle.rs | 328 | L1 外挂字幕(.srt/.ass/.vtt)纯文本解析,零第三方依赖——(自动摘取,待细化) | 若再增长:按职责拆分 |
128128
| app/src-tauri/src/commands_ai_chat.rs | 324 | v0.16 对话命令域 + v0.19.1(REQ-260)检索分支薄壳(纯聊链路零改动;kb 编排已拆至 commands_ai_chat_kb.rs 267 行)——检索分流点与 run_stream 共用会话编排上下文 | 若再增长:run_stream 与纯聊发送拆至 commands_ai_chat_plain.rs |
129-
| app/src-tauri/src/types.rs | 324 | 拆分进行中(批 0-C3 Task 2):原 1017 行全局共享类型域已按域拆出 types_session.rs / types_knowledge.rs;本文件现为 `#[path]` 门面 + 尚未搬出的域,终态 ≈27 行纯门面(6 个子模块 + 6 个 `pub use`,crate::types::X 全仓 236 处引用零改动) | S3 types_note.rs → S4 types_ocr.rs → S5 types_extract.rs → S6 types_decision.rs(v0.13.8 画布契约随 decision 以满足 ≤300);每步一个提交 |
130129
| app/src/components/EnrichPanel.tsx | 324 | 与精修语义分开:精修=处理已有内容,补充=生成新内容(模型(自动摘取,待细化) | 若再增长:按职责拆分 |
131130
| app/src/components/NoteReadingView.tsx | 322 | v0.20.13 批 8(REQ-317)正文选区右键菜单接线(正文容器 ref 化 + 选区判定/全选 + 共享菜单渲染,259→316)——选区纯逻辑已拆 utils/noteSelectionMenu.ts 与 note-selection/SelectionActionMenu.tsx,本文件仅留宿主接线 | 若再增长:搜索态/选区态/大纲态拆至 useNoteReadingViewState.ts |
132131
| app/src-tauri/src/ai_note_refine.rs | 321 | v0.8.0 M2 + REQ-290①:精修适配器域(提示词装配/请求响应类型/非流式 refine/流式 NDJSON 逐节/预算接线)+ 2026-09-11 provider 策略落体(流式拍不走 post_completions);协议与适配器同域便于 schema v2 一致性 | 若再增长:流式 NDJSON 路径拆至 ai_note_refine_stream.rs |

0 commit comments

Comments
 (0)