# UIActionScheduler 统一调度方案 > 文档版本:v1.0 > 创建日期:2026-07-17 > 范围:`bridge/woc_bridge` 全链路 > 目标:将散落在 5 个子系统的 UI 操作收口到统一调度器,消除绕过队列的竞态风险,引入优先级与可观测性 --- ## 一、背景与现状 ### 1.1 当前架构 当前 UI 操作分散在 5 个子系统,串行化覆盖不完整: | 子系统 | 入口 | 是否经 SendQueue | 互斥情况 | |---|---|---|---| | `FlowOrchestrator.send_text/send_file` | HTTP + BatchWorker | ✅ 入队 | SendQueue 串行 + `_ui_lock` 单命令互斥 | | `routes/contacts.py` (set_remark/add_friend/accept) | HTTP | ✅ 入队 | 同上 | | `routes/moments.py` (publish/share/like/comment/delete) | HTTP | ✅ 入队 | 同上 | | `routes/send.py` legacy (revoke/forward) | HTTP | ✅ 入队 | 同上 | | `FriendRequestWatcher._handle_request` | 后台轮询 3s | ✅ 入队 | 同上 | | `routes/login.py` (logout/restart) | HTTP | ❌ 直接调 xdotool | 仅 `_ui_lock` 单命令互斥 | | `routes/login.py` (qr_start/qr_wait) | HTTP | ❌ 直接调 xdotool | qr_capture **无锁** | | `routes/screenshot.py` | HTTP | ❌ 直接调 qr_capture | **无锁** | | `routes/diagnostic.py` (autofix) | HTTP | ❌ 内联 pkill | **无锁** | | `LoginGuard._check_loop` | 后台轮询 5s | ❌ 直接调 xdotool | 仅 `_ui_lock` | | `WeChatWatchdog._run` | 后台轮询 10s | ❌ 直接调 pgrep/kill | **无锁** | ### 1.2 已确认的问题 #### P0 竞态风险(已发生过实际故障) | 问题 | 根因 | 后果 | 引用 | |---|---|---|---| | friend_watcher 与 send_text 竞态 | 早期未入队,现已修复入队 | erratic clicking、重复拨号 | 项目记忆记录 | | logout 多步操作被 send_queue 任务穿插 | logout 不入队,仅靠 `_ui_lock` 单命令互斥 | 菜单状态错乱、登出失败 | [routes/login.py:201](../../bridge/woc_bridge/routes/login.py#L201) | | restart kill 微信与并发 UI 操作冲突 | restart 不入队 | xdotool 对空窗口操作、TimeoutError 雪崩 | [routes/login.py:281](../../bridge/woc_bridge/routes/login.py#L281) | | diagnostic autofix pkill 无锁 | 直接 `pkill -x wechat` | 与并发 UI 操作冲突高风险 | [routes/diagnostic.py:232-239](../../bridge/woc_bridge/routes/diagnostic.py#L232) | | QrCapture 无锁无超时 | `proc.communicate()` 无 timeout | 与 send_text 截图竞争 X11、可能无限阻塞 | [ui/qr_capture.py:48-57](../../bridge/woc_bridge/ui/qr_capture.py#L48) | | WeChatWatchdog autofix 无锁 | pgrep+kill 不持 `_ui_lock` | kill 微信时 UI 操作中途失败 | [ui/watchdog.py:99-191](../../bridge/woc_bridge/ui/watchdog.py#L99) | #### P1 设计债务 | 问题 | 根因 | 后果 | |---|---|---| | 无优先级机制 | SendQueue 是纯 FIFO | 紧急操作(logout/restart)无法插队,100 个 send 排队时 logout 延迟分钟级 | | 命名与实现不符 | 类名 `SendQueue`,实际是通用 UI 队列 | 误导维护者认为只管 send | | 路由层前置 `detect_login_state` 不入队 | 多处路由在 enqueue 前直接调 xdotool | 与 send_queue 内任务竞争 `_ui_lock`,增加排队延迟 | | 启动清场不入队 | `_full_cleanup_on_startup` 直接调 | 若 friend_watcher 已启动可能并发(实际启动顺序规避了此风险) | | 无统一可观测性 | 各子系统独立打日志,无统一 metrics | UI 操作队列深度、等待时长、执行时长无法监控 | #### P1.1 detect_login_state 散落调用分析 `detect_login_state` 是只读探测(截图 + 模板匹配),但散落在 14 处直接调用,每处都会与 send_queue 内任务竞争 L1 `_ui_lock`: | 文件 | 调用位置 | 调用频率 | 与队列竞争影响 | |---|---|---|---| | `routes/send.py` | L307 / L413 / L587 / L690 / L774 / L852(6 处) | 每次 HTTP 请求 | 每次持锁 ~200-500ms(screenshot+模板匹配),100 并发 send 时累计 12-30s 额外延迟 | | `routes/moments.py` | L151 / L255 / L346 / L447 / L545 / L677(6 处) | 每次 HTTP 请求 | 同上 | | `routes/contacts.py` | L236 / L313 / L433(3 处) | 每次 HTTP 请求 | 同上 | | `routes/login.py` | L117 / L190(2 处) | 每次 HTTP 请求 | 同上 | | `routes/diagnostic.py` | L114(1 处) | 诊断触发 | 频率低,影响小 | | `routes/status.py` | L51-56(内联判定,未直接调) | 每次 status 请求 | 已优化为内联 pgrep/find_window,**不持 _ui_lock** | | `LoginGuard._check_loop` | 后台 5s 轮询 | 持续运行 | 每 5s 一次,频率低 | | `friend_watcher._verify_with_retry` | 后台触发 | 偶发 | 频率低 | **关键发现**:`routes/status.py` 已经通过内联 pgrep+find_window 避开了 `detect_login_state`,证明这种"只读探测不持 _ui_lock"的优化路径是可行且被项目采纳的。 **处理方案**(**本方案不强制收口,但记录优化路径**): 1. **短期(本方案不实施)**:保持现状。`detect_login_state` 是单次截图+模板匹配,持锁时间可控(< 1s)。在 send_queue 任务执行期间(通常 1-5s),最多 1-2 次 detect_login_state 抢占 _ui_lock,影响有限。 2. **中期(独立优化项)**:参照 `routes/status.py` 的优化模式,将路由前置 `detect_login_state` 改为内联 pgrep + find_window 判定(不持 _ui_lock),仅在判定为 "logged_in" 后才入队执行真正的 UI 操作。 3. **长期(架构演进)**:LoginGuard 维护登录态缓存,路由层读缓存而非每次调 `detect_login_state`。 **为什么 UIActionScheduler 不收口 detect_login_state**: - 入队会被 100 个 send 排队阻塞,导致登录态检测延迟分钟级,影响 friend_watcher 等依赖登录态的后台任务 - detect_login_state 是只读探测,不修改 UI 状态,与 send_queue 任务的"互斥"是性能问题而非正确性问题 - 收口 detect_login_state 会引入"队列内任务触发队列外只读探测"的循环依赖 **风险评估**:保持现状的代价是每个 send 请求多 ~300ms 延迟(detect_login_state 持锁 1 次),在 100 并发场景下累计 ~30s。可通过中期方案消除。 ### 1.3 SendQueue 现状评估 SendQueue 实际上**已经是事实上的通用 UI 调度器**: - 16 处 `enqueue` 调用点,覆盖 send(4)/contacts(3)/moments(6)/friend_watcher(1)/orchestrator(2) - 接收任意 `CoroFactory`,不限定 send 语义 - 单 worker 串行 + 1 秒滑窗限流 + 队列满拒绝 + 等待超时 **可直接复用为基础**,但需扩展: 1. 增加 `priority` 参数(当前队列元素是 `tuple[CoroFactory, Future, Optional[int]]`,第三段是 `custom_delay_ms`,无 priority 槽位) 2. 重命名以反映通用语义(向后兼容保留别名) 3. 收口绕过队列的调用点 ### 1.4 双层保护模型(L1 _ui_lock + L2 SendQueue) 当前架构存在两层互斥机制,理解其分工是设计 UIActionScheduler 的前提: | 层级 | 互斥粒度 | 实现位置 | 持锁时长 | 保护语义 | |---|---|---|---|---| | **L1** `_ui_lock` | **单命令**(一次 click/type/screenshot) | `ui/backends/xdotool.py:50` `asyncio.Lock` | 单次 xdotool 子进程(~50ms-5s) | 防止两个 asyncio 协程同时调 xdotool 导致 X11 焦点错乱 | | **L2** `SendQueue` | **多命令 Flow**(整个 send_text/logout 流程) | `messaging/send_queue.py` worker 串行 | 整个 Flow(~1-30s) | 防止多步操作之间被其他操作步骤穿插 | **关键区别**: ``` send_text Flow(多步操作,L2 保护): activate → click_search_box → type_query → open_session → focus_input → type_text → click_send → verify ↑ 每一步内部都 acquire/release L1 _ui_lock(单命令互斥) ↑ 整个 Flow 由 L2 SendQueue 串行执行(多步不被穿插) logout Flow(多步操作,当前仅 L1 保护): activate → click_main_menu → key_down × N → enter → click_confirm ↑ 每一步内部 acquire/release L1 _ui_lock ↑ 整个 Flow 不在 SendQueue 中 → send_text 的步骤可以穿插进来! ``` **核心问题**:L1 只保护单次命令,无法防止多步 Flow 被穿插。例如 logout 点击主菜单后释放 _ui_lock,send_text 立即获得 _ui_lock 执行 click_search_box,导致 logout 的下一步 key_down 落在错误的焦点上。 **UIActionScheduler 的职责**:提供 L2 层统一保护,所有多步 UI 操作必须入队,避免步骤穿插。L1 保持不变作为底层单命令互斥的第二道防线(防止绕过队列的极端情况,如 LoginGuard/detect_login_state)。 ### 1.5 UI 操作调用点全景图 **修改前**(当前状态,散落 22 个调用点,仅 16 个入队): ``` ┌─ HTTP 路由层 ────────────────────────────────────────────────────────────┐ │ │ │ routes/send.py │ │ ├─ send_text (L337) ── enqueue ──→ SendQueue [✅ 入队] │ │ ├─ send_file (L703) ── enqueue ──→ SendQueue [✅ 入队] │ │ ├─ revoke_message (L787) ── enqueue ──→ SendQueue [✅ 入队] │ │ ├─ forward_message (L881) ── enqueue ──→ SendQueue [✅ 入队] │ │ ├─ detect_login_state × 6 ── direct ──→ xdotool [⚠️ 仅 L1] │ │ ├─ post_verify_revoke ── direct ──→ DB only [✅ 无 UI] │ │ ├─ capture_forward_baseline ── direct ──→ DB only [✅ 无 UI] │ │ └─ post_verify_forward ── direct ──→ DB only [✅ 无 UI] │ │ │ │ routes/contacts.py │ │ ├─ set_remark (L249) ── enqueue ──→ SendQueue [✅ 入队] │ │ ├─ add_friend (L321) ── enqueue ──→ SendQueue [✅ 入队] │ │ ├─ accept_friend (L452) ── enqueue ──→ SendQueue [✅ 入队] │ │ ├─ detect_login_state × 3 ── direct ──→ xdotool [⚠️ 仅 L1] │ │ └─ post_verify_set_remark ── direct ──→ DB only [✅ 无 UI] │ │ post_verify_add_friend │ │ │ │ routes/moments.py │ │ ├─ publish (L167) ── enqueue ──→ SendQueue [✅ 入队] │ │ ├─ share (L271) ── enqueue ──→ SendQueue [✅ 入队] │ │ ├─ like (L379) ── enqueue ──→ SendQueue [✅ 入队] │ │ │ └─ _like_and_verify (含 post_verify_moment_like 含截图) │ │ ├─ comment (L480) ── enqueue ──→ SendQueue [✅ 入队] │ │ │ └─ _comment_and_verify (含 post_verify_moment_comment 含截图) │ │ ├─ delete (L583) ── enqueue ──→ SendQueue [✅ 入队] │ │ ├─ forward_moment (L692) ── enqueue ──→ SendQueue [✅ 入队] │ │ └─ detect_login_state × 6 ── direct ──→ xdotool [⚠️ 仅 L1] │ │ │ │ routes/login.py │ │ ├─ qr_start (L58-59) ── direct ──→ xdotool+qr [❌ P0 无锁] │ │ ├─ qr_wait (L117) ── direct ──→ xdotool [⚠️ 仅 L1] │ │ ├─ logout (L201) ── direct ──→ xdotool [❌ P0 仅 L1] │ │ └─ wechat_restart (L281) ── direct ──→ xdotool [❌ P0 仅 L1] │ │ │ │ routes/screenshot.py │ │ └─ screenshot (L42) ── direct ──→ qr_capture [❌ P0 无锁] │ │ │ │ routes/diagnostic.py │ │ ├─ autofix_wechat_running ── direct ──→ pkill [❌ P0 无锁] │ │ └─ detect_login_state (L114)── direct ──→ xdotool [⚠️ 仅 L1] │ │ │ │ routes/status.py │ │ └─ 内联 pgrep+find_window (不持 _ui_lock,已优化) [✅ 无 UI] │ │ │ └──────────────────────────────────────────────────────────────────────────┘ ┌─ 后台任务层 ────────────────────────────────────────────────────────────┐ │ │ │ FlowOrchestrator (send_text/send_file) │ │ ├─ enqueue (L231, L364) ── enqueue ──→ SendQueue [✅ 入队] │ │ └─ SessionCache LRU │ │ │ │ FriendRequestWatcher._handle_request │ │ └─ enqueue (L506) ── enqueue ──→ SendQueue [✅ 入队] │ │ │ │ LoginGuard._check_loop │ │ └─ detect_login_state ── direct ──→ xdotool [⚠️ 仅 L1] │ │ │ │ WeChatWatchdog._run │ │ ├─ pgrep/xdpyinfo ── direct ──→ system [✅ 只读] │ │ └─ _autofix_wechat (kill) ── direct ──→ kill [❌ P0 无锁] │ │ │ │ QrCapture │ │ └─ _run (proc.communicate) ── direct ──→ scrot [❌ P0 无超时] │ │ │ └──────────────────────────────────────────────────────────────────────────┘ ``` **修改后**(目标状态,所有 P0 收口,仅保留只读探测不入队): ``` ┌─ HTTP 路由层 ── 全部经 UIActionScheduler.execute(action, priority) ────┐ │ │ │ routes/send.py │ │ ├─ send_text/file/revoke/forward ── execute(NORMAL) ──→ Scheduler │ │ ├─ detect_login_state × 6 ── direct (中期优化为内联 pgrep) │ │ └─ post_verify_* / capture_baseline ── direct (DB only, 保留) │ │ │ │ routes/contacts.py │ │ ├─ set_remark/add_friend/accept ── execute(NORMAL/HIGH) → Scheduler │ │ └─ detect_login_state × 3 ── direct (中期优化) │ │ │ │ routes/moments.py │ │ ├─ publish/share/delete ── execute(NORMAL) ──→ Scheduler │ │ ├─ like/comment ── execute(LOW) ──→ Scheduler │ │ └─ detect_login_state × 6 ── direct (中期优化) │ │ │ │ routes/login.py │ │ ├─ qr_start (capture_qr_code) ── execute(HIGH) ──→ Scheduler │ │ ├─ logout ── execute(HIGH) ──→ Scheduler │ │ ├─ wechat_restart ── execute(CRITICAL)──→ Scheduler │ │ │ └─ mark_wechat_dead(8.0) before execute │ │ └─ qr_wait (detect_login_state) ── direct (只读,保留) │ │ │ │ routes/screenshot.py │ │ └─ screenshot ── execute(LOW) ──→ Scheduler │ │ │ │ routes/diagnostic.py │ │ └─ autofix_wechat_running ── execute(CRITICAL)──→ Scheduler │ │ └─ mark_wechat_dead(15.0) before execute │ │ │ └──────────────────────────────────────────────────────────────────────────┘ ┌─ 后台任务层 ────────────────────────────────────────────────────────────┐ │ │ │ FlowOrchestrator.send_text/send_file │ │ └─ enqueue (向后兼容) ── execute(NORMAL) ──→ Scheduler │ │ │ │ FriendRequestWatcher._handle_request │ │ └─ enqueue (向后兼容) ── execute(HIGH) ──→ Scheduler │ │ │ │ LoginGuard._check_loop │ │ └─ detect_login_state ── direct (只读,保留) │ │ │ │ WeChatWatchdog._autofix_wechat │ │ ├─ scheduler.drain(5.0) ── 等待队列清空 │ │ ├─ scheduler.mark_wechat_dead(15.0) ── 标记 fast-fail 窗口 │ │ └─ pgrep + kill -TERM ── 系统级操作(不入队) │ │ │ │ QrCapture._run │ │ └─ asyncio.wait_for(proc.communicate, 5.0) ── 超时保护 │ │ │ └──────────────────────────────────────────────────────────────────────────┘ ``` ### 1.6 HTTP 端点 → 优先级映射表 | HTTP 端点 | 当前状态 | 目标优先级 | delay_ms 策略 | wait_timeout_ms | 备注 | |---|---|---|---|---|---| | `POST /api/send/text` | ✅ enqueue | NORMAL | 同联系人 1000 / 不同 3000 | 15000 | 现有逻辑不变 | | `POST /api/send/file` | ✅ enqueue | NORMAL | 默认 3000 | 15000 | 现有逻辑不变 | | `POST /api/messages/revoke` | ✅ enqueue | NORMAL | 默认 3000 | 15000 | 现有逻辑不变 | | `POST /api/messages/forward` | ✅ enqueue | NORMAL | 默认 3000 | 15000 | 现有逻辑不变 | | `POST /api/friends/remark` | ✅ enqueue | NORMAL | 默认 3000 | 15000 | 现有逻辑不变 | | `POST /api/friends/add` | ✅ enqueue | NORMAL | 默认 3000 | 15000 | 现有逻辑不变 | | `POST /api/friends/accept` | ✅ enqueue | HIGH | 默认 3000 | 30000 | 时效性高 | | `POST /api/moments/publish` | ✅ enqueue | NORMAL | 默认 3000 | 30000 | 现有逻辑不变 | | `POST /api/moments/share` | ✅ enqueue | NORMAL | 默认 3000 | 30000 | 现有逻辑不变 | | `POST /api/moments/delete` | ✅ enqueue | NORMAL | 默认 3000 | 15000 | 现有逻辑不变 | | `POST /api/moments/forward` | ✅ enqueue | NORMAL | 默认 3000 | 30000 | 现有逻辑不变 | | `POST /api/moments/like` | ✅ enqueue | LOW | 默认 3000 | 15000 | 可延迟 | | `POST /api/moments/comment` | ✅ enqueue | LOW | 默认 3000 | 15000 | 可延迟 | | `POST /api/screenshot` | ❌ direct | LOW | 默认 3000 | 10000 | 阶段 2 收口 | | `POST /api/login/qr/start` | ❌ direct | HIGH | 默认 3000 | 15000 | 阶段 2 收口 | | `POST /api/login/logout` | ❌ direct | HIGH | 默认 3000 | 30000 | 阶段 2 收口 | | `POST /api/wechat/restart` | ❌ direct | CRITICAL | 0(自动) | 60000 | 阶段 2 收口 + fast-fail | | `POST /api/diagnostic/autofix/wechat_running` | ❌ direct | CRITICAL | 0(自动) | 30000 | 阶段 2 收口 + fast-fail | | `GET /api/login/qr/wait` | direct(轮询) | 不入队 | - | - | 只读探测 | | `GET /api/status` | 内联 pgrep | 不入队 | - | - | 已优化 | | `GET /api/diagnostic/run/*` | direct(只读) | 不入队 | - | - | 只读探测 | --- ## 二、设计方案 ### 2.1 核心思路 **不推翻 SendQueue 重写,而是扩展 + 收口**: 1. **扩展 SendQueue** 为 `UIActionScheduler`,增加优先级参数 2. **收口绕过队列的 6 个调用点**,统一入队 3. **保留 `_ui_lock`** 作为底层单命令互斥(不取消,作为第二道防线) 4. **新增 metrics** 暴露队列深度、等待时长、执行时长 ### 2.2 命名策略 采用**渐进式重命名**,避免一次性破坏全部调用点: ```python # messaging/ui_action_scheduler.py(新文件,继承 SendQueue) class UIActionScheduler(SendQueue): """UI 操作统一调度器。 在 SendQueue 基础上增加: - priority 参数(CRITICAL/HIGH/NORMAL/LOW) - 优先级队列(asyncio.PriorityQueue 替代 asyncio.Queue) - metrics 暴露(队列深度、等待时长、执行时长) """ async def execute( self, action: CoroFactory, priority: Priority = Priority.NORMAL, delay_ms: Optional[int] = None, wait_timeout_ms: Optional[int] = None, trace_id: str = "", ) -> Any: """提交 UI 操作,按优先级调度,串行执行。""" ... # config.py _state.send_queue: SendQueue | UIActionScheduler # 渐进式,先保持字段名 ``` **向后兼容**: - 保留 `SendQueue.enqueue` 方法签名不变(priority 默认 NORMAL) - 16 个现有 `enqueue` 调用点无需修改 - 新调用点用 `execute` 方法,语义更清晰 ### 2.3 优先级策略 ```python class Priority(enum.IntEnum): """UI 操作优先级(数字越小优先级越高)。""" CRITICAL = 0 # 系统级紧急:wechat_restart、diagnostic_autofix HIGH = 1 # 用户感知延迟:logout、friend_accept、login_qr_capture NORMAL = 2 # 常规业务:send_text、send_file、set_remark、add_friend LOW = 3 # 可延迟:screenshot、moments_like、moments_comment ``` **优先级队列实现**: - 使用 `asyncio.PriorityQueue`,元素为 `(priority, seq, coro_factory, future, delay_ms)` - `seq` 是单调递增序列号,保证同优先级 FIFO(避免 coro_factory 不可比较导致的错误) - worker 出队时按 `(priority, seq)` 排序 **优先级反转保护**: - 低优先级操作持锁时,高优先级操作必须等待(无法抢占,xdotool 子进程不可中断) - 缓解:限制单次操作超时(已有 `_Cmd_TIMEOUT_SEC=5.0` + Flow 30s 超时),避免低优先级操作长时间持锁 - 不实现优先级继承(xdotool 子进程无法感知调用方优先级) #### 2.3.1 delay_ms 与优先级的交互策略 **当前 delay_ms 决策逻辑**(在 `orchestrator.py:212`): - 同联系人发送:`delay_ms=1000`(短延时,提升吞吐) - 不同联系人发送:`delay_ms=None` → 用 `send_delay_ms=3000`(默认延时,避免风控) - 路由层 `enqueue` 不传 `delay_ms` → 用默认 `send_delay_ms=3000` **问题**:引入优先级后,CRITICAL/HIGH 操作的延时是否应区别处理? **分析**: - delay_ms 是任务**执行后**的等待时间,影响**下一个任务**的开始时机 - 例如:CRITICAL 的 restart 执行后,若 delay_ms=3000,下一个 NORMAL 任务要等 3s - restart 本身的优先级已经让它排到队首,所以 delay_ms 不影响 restart 自身延迟 **策略**: | 优先级 | delay_ms 策略 | 理由 | |---|---|---| | CRITICAL | `delay_ms=0`(不延时) | restart/autofix 后应立即让后续任务执行,避免无谓等待 | | HIGH | `delay_ms=None`(用默认) | logout/accept_friend 后需要给微信 UI 恢复时间,保持默认延时 | | NORMAL | 保持现有逻辑(同联系人 1000 / 不同 3000) | send 风控保护,不变 | | LOW | `delay_ms=None`(用默认) | screenshot 后不强制延时,但也不加速,避免连续截图 | **实现**:在 `scheduler.execute` 内根据 priority 自动覆盖 delay_ms: ```python async def execute( self, action: CoroFactory, priority: Priority = Priority.NORMAL, delay_ms: Optional[int] = None, ... ) -> Any: # CRITICAL 任务自动应用 delay_ms=0(除非调用方显式指定) if priority == Priority.CRITICAL and delay_ms is None: delay_ms = 0 return await self._enqueue_with_priority( action, priority, delay_ms, wait_timeout_ms, trace_id ) ``` **注意**:调用方显式指定 `delay_ms` 时优先尊重调用方意图(如批量发送要求固定间隔)。 **对现有 enqueue 的影响**:无。现有 16 个 `enqueue` 调用点都走 `priority=NORMAL`,delay_ms 决策逻辑不变。 ### 2.4 关键决策:watchdog 是否入队 **不入队,但加协同机制**: WeChatWatchdog 的 `pgrep -x wechat` / `xdpyinfo` / `kill -TERM` 是**系统级操作**,不是 UI 操作: - `pgrep`/`xdpyinfo`:只读探测,不修改 UI 状态,无需互斥 - `kill -TERM`:会杀死微信进程,导致所有进行中的 UI 操作失败 **方案**: - watchdog 的 `pgrep`/`xdpyinfo` 保持现状(不入队,不加锁) - watchdog 的 `_autofix_wechat` 在 kill 前检查 `scheduler.pending_count()`: - 若有 pending UI 操作,先 `await scheduler.drain(timeout=5.0)` 等待队列清空 - 超时未清空则强制 kill(记 warning) - kill 后广播 `wechat_killed` 事件,所有进行中的 Flow 捕获 `WINDOW_NOT_FOUND` 后自然失败 ### 2.5 关键决策:QrCapture 如何收口 QrCapture 有两类操作: 1. `capture_qr_code`:启动扫码登录流程,**多步 UI 操作**(activate + screenshot + crop) 2. `capture_full_screenshot`:单次截图 **方案**: - `capture_qr_code` 入队(priority=HIGH),与 send_text 串行 - `capture_full_screenshot` 入队(priority=LOW) - QrCapture 内部增加 `_CMD_TIMEOUT_SEC=5.0` 包裹 `proc.communicate()`,消除无超时风险 ### 2.6 关键决策:LoginGuard 是否入队 **不入队**,原因: - `detect_login_state` 是只读探测(截图 + 模板匹配),不修改 UI 状态 - 5 秒轮询 + `_ui_lock` 单命令互斥已足够 - 若入队,会被 100 个 send 排队阻塞,登录态检测延迟可达分钟级,影响 friend_watcher 等依赖登录态的任务 **但需加保护**: - `detect_login_state` 内部已有 `_ui_lock` 持锁(经 `_run`),保持现状 - 增加监控:若 `_ui_lock` 等待时长 > 2s,记 warning(说明 UI 操作积压) --- ## 三、详细设计 ### 3.1 UIActionScheduler 类 ```python # bridge/woc_bridge/messaging/ui_action_scheduler.py """UI 操作统一调度器。 所有 xdotool/scrot 子进程调用经此调度器串行执行,避免并发 UI 操作冲突。 在 SendQueue 基础上增加优先级调度与可观测性。 """ from __future__ import annotations import asyncio import enum import logging import time from typing import Any, Awaitable, Callable, Optional from woc_bridge.messaging.send_queue import SendQueue, CoroFactory from woc_bridge.models import BridgeError logger = logging.getLogger("woc-bridge") class Priority(enum.IntEnum): """UI 操作优先级(数字越小优先级越高)。""" CRITICAL = 0 # 系统级紧急:wechat_restart、diagnostic_autofix HIGH = 1 # 用户感知延迟:logout、friend_accept、login_qr_capture NORMAL = 2 # 常规业务:send_text、send_file、set_remark、add_friend LOW = 3 # 可延迟:screenshot、moments_like、moments_comment class UIActionScheduler(SendQueue): """UI 操作统一调度器。 扩展 SendQueue: - execute() 方法支持 priority 参数 - 内部用 asyncio.PriorityQueue 替代 asyncio.Queue(覆盖父类 __init__ 创建的 Queue) - 同优先级 FIFO(通过 seq 序列号保证,避免比较到不可比较的 coro_factory) - metrics 暴露(pending_count by priority、wait_duration、exec_duration) 向后兼容: - enqueue() 方法保留,priority 默认 NORMAL - 现有 16 个 enqueue 调用点无需修改 ⚠️ 必须覆盖父类 _run 方法:父类 _run 解包 3 元组 (coro_factory, future, delay_ms), 子类用 5 元组 (priority, seq, coro_factory, future, delay_ms)。 若不覆盖会导致解包失败。 """ def __init__( self, send_delay_ms: int = 3000, max_calls_per_sec: int = 10, max_queue_size: int = 100, ) -> None: super().__init__(send_delay_ms, max_calls_per_sec, max_queue_size) # 覆盖父类 __init__ 创建的 asyncio.Queue 为 PriorityQueue # 父类的旧 Queue 对象会被 GC 回收(无其他引用) self._queue: asyncio.PriorityQueue[ tuple[int, int, CoroFactory, asyncio.Future, Optional[int]] ] = asyncio.PriorityQueue(maxsize=max_queue_size) self._seq = 0 # 单调递增序列号,保证同优先级 FIFO # fast-fail 机制:mark_wechat_dead 标记的时间戳 # 在此时间之前,所有非 CRITICAL 任务直接抛 WECHAT_NOT_READY # 供 watchdog kill / wechat_restart 调用,避免失败风暴 self._wechat_dead_until: float = 0.0 # metrics(简单计数器,供 /api/status 或 Prometheus 暴露) # asyncio 单线程事件循环,worker 与 HTTP handler 同线程,无需加锁 self._metrics: dict[str, Any] = { "executed_total": 0, "executed_by_priority": {p.name: 0 for p in Priority}, "wait_duration_ms_sum": 0.0, "exec_duration_ms_sum": 0.0, "timeout_total": 0, "rate_limited_total": 0, "fast_fail_total": 0, # 被 mark_wechat_dead 拦截的任务数 } def mark_wechat_dead(self, duration_sec: float = 10.0) -> None: """标记微信已死,期间所有非 CRITICAL 任务直接 fast-fail。 供 watchdog kill / wechat_restart / diagnostic autofix 调用: - kill 前调 mark_wechat_dead(15.0),覆盖 autostart 拉起窗口 - restart 开始时调 mark_wechat_dead(8.0),覆盖新进程启动窗口 CRITICAL 任务(如 restart 本身)不受影响,确保 restart 能正常执行。 Args: duration_sec: fast-fail 窗口时长(秒) """ self._wechat_dead_until = time.monotonic() + duration_sec logger.warning( "[scheduler] mark_wechat_dead %.1fs, %d pending tasks will fast-fail", duration_sec, self._queue.qsize(), ) async def execute( self, action: CoroFactory, priority: Priority = Priority.NORMAL, delay_ms: Optional[int] = None, wait_timeout_ms: Optional[int] = None, trace_id: str = "", ) -> Any: """提交 UI 操作,按优先级调度,串行执行。 Args: action: 返回 coroutine 的工厂函数 priority: 优先级(CRITICAL/HIGH/NORMAL/LOW) delay_ms: 自定义本次延时毫秒;None 时: - CRITICAL 自动设为 0(执行后不延时) - 其他优先级用默认 send_delay_ms wait_timeout_ms: 队列等待超时;None 无限等待 trace_id: 追踪 ID(用于日志关联) Returns: coroutine 的实际执行结果 Raises: BridgeError(RATE_LIMITED): 队列满 BridgeError(TIMEOUT): 等待超时 """ # CRITICAL 任务自动应用 delay_ms=0(除非调用方显式指定) # 参见 2.3.1 节 delay_ms 与优先级交互策略 if priority == Priority.CRITICAL and delay_ms is None: delay_ms = 0 return await self._enqueue_with_priority( action, priority, delay_ms, wait_timeout_ms, trace_id ) async def _enqueue_with_priority( self, coro_factory: CoroFactory, priority: Priority, delay_ms: Optional[int], wait_timeout_ms: Optional[int], trace_id: str, ) -> Any: """优先级入队(覆盖 SendQueue.enqueue 的内部实现)。 与父类 enqueue 的差异: - 用 put_nowait 替代 await put(满队时立即抛错,语义更清晰) - 队列元素从 3 元组扩展为 5 元组(priority, seq, coro_factory, future, delay_ms) - 增加入队日志(与父类保持一致的可观测性) """ loop = asyncio.get_running_loop() future: asyncio.Future = loop.create_future() self._seq += 1 item = (int(priority), self._seq, coro_factory, future, delay_ms) # 入队(带队列满检查,与父类行为一致) try: self._queue.put_nowait(item) except asyncio.QueueFull: self._metrics["rate_limited_total"] += 1 logger.warning( "[scheduler] 队列已满 (size=%d/%d),拒绝入队 priority=%s", self._queue.maxsize, self._queue.maxsize, priority.name, ) raise BridgeError( code="RATE_LIMITED", message=f"UI 调度器队列已满({self._queue.maxsize}),请稍后重试", details={"retry_after": 3}, ) pending = self._queue.qsize() logger.info( "[scheduler] 入队 priority=%s pending=%d trace_id=%s", priority.name, pending, trace_id or "(none)", ) # 等待结果(与父类逻辑一致,保留竞争窗口处理) wait_start = time.monotonic() if wait_timeout_ms is not None and wait_timeout_ms > 0: try: result = await asyncio.wait_for( future, timeout=wait_timeout_ms / 1000.0 ) except asyncio.TimeoutError: # 竞争窗口:worker 可能刚好在此时完成并 set_result if future.done() and not future.cancelled(): logger.info( "[scheduler] 等待超时但任务刚好完成,取结果 (pending=%d)", pending, ) return future.result() future.cancel() self._metrics["timeout_total"] += 1 wait_sec = wait_timeout_ms / 1000.0 logger.warning( "[scheduler] 等待超时 (pending=%d, wait_timeout=%.1fs, priority=%s)", pending, wait_sec, priority.name, ) raise BridgeError( code="TIMEOUT", message=f"UI 调度器等待超时({pending} 个待处理,已等 {wait_sec:.1f}s)", details={"retry_after": max(1, int(self.send_delay_ms / 1000))}, ) else: result = await future # 记录等待时长 wait_ms = (time.monotonic() - wait_start) * 1000 self._metrics["wait_duration_ms_sum"] += wait_ms return result async def enqueue( self, coro_factory: CoroFactory, delay_ms: Optional[int] = None, wait_timeout_ms: Optional[int] = None, ) -> Any: """向后兼容的入队方法(priority 默认 NORMAL)。 现有 16 个 enqueue 调用点无需修改,行为与父类一致。 """ return await self._enqueue_with_priority( coro_factory, Priority.NORMAL, delay_ms, wait_timeout_ms, "" ) async def _run(self) -> None: """worker 主循环。 ⚠️ 必须覆盖父类 _run:父类解包 3 元组,子类用 5 元组。 与父类 _run 的差异: - 解包 5 元组 (priority, seq, coro_factory, future, custom_delay_ms) - 增加 metrics 记录(executed_total / executed_by_priority / exec_duration) - 保留限流检查(_check_rate_limit 继承父类,不覆盖) - 保留延时逻辑(delay_ms 优先于 send_delay_ms) - task_done 调用与父类一致:cancelled 分支和 finally 分支互斥 """ while True: try: priority, seq, coro_factory, future, custom_delay_ms = ( await self._queue.get() ) except asyncio.CancelledError: raise # 调用方已超时取消:跳过执行与延时(与父类一致) if future.cancelled(): self._queue.task_done() logger.info( "[scheduler] 出队任务已取消,跳过 (pending=%d, priority=%s)", self._queue.qsize(), Priority(priority).name if 0 <= priority <= 3 else "?", ) continue # fast-fail 检查:微信已死期间,非 CRITICAL 任务直接失败 # 参见 6.4 节 失败风暴问题与 fast-fail 机制 if ( self._wechat_dead_until > time.monotonic() and priority > Priority.CRITICAL # CRITICAL 任务(=0)不受影响 ): logger.info( "[scheduler] fast-fail (wechat dead, %.1fs remaining, priority=%s)", self._wechat_dead_until - time.monotonic(), Priority(priority).name if 0 <= priority <= 3 else "?", ) if not future.done(): future.set_exception(BridgeError( code="WECHAT_NOT_READY", message="微信进程未就绪(重启中),请稍后重试", details={"retry_after": 5}, )) self._metrics["fast_fail_total"] += 1 self._queue.task_done() continue # 不延时,立即处理下一个 logger.info( "[scheduler] 出队,开始处理 (pending=%d, priority=%s)", self._queue.qsize(), Priority(priority).name if 0 <= priority <= 3 else "?", ) executed = False t_exec = time.perf_counter() try: # 继承父类的限流检查 self._check_rate_limit() self._recent_call_times.append(time.monotonic()) executed = True logger.info("[scheduler] 开始执行任务 (priority=%s)", Priority(priority).name) result = await coro_factory() logger.info( "[scheduler] 任务执行完成 (%.0fms, priority=%s)", (time.perf_counter() - t_exec) * 1000, Priority(priority).name, ) if not future.done(): future.set_result(result) except asyncio.CancelledError: if not future.done(): future.cancel() raise except Exception as e: logger.warning( "[scheduler] 任务执行抛异常 %s: %s (%.0fms, priority=%s)", type(e).__name__, e, (time.perf_counter() - t_exec) * 1000, Priority(priority).name, ) if not future.done(): future.set_exception(e) finally: self._queue.task_done() if executed: # 更新 metrics exec_ms = (time.perf_counter() - t_exec) * 1000 self._metrics["executed_total"] += 1 self._metrics["exec_duration_ms_sum"] += exec_ms try: p = Priority(priority) self._metrics["executed_by_priority"][p.name] += 1 except ValueError: pass delay = ( custom_delay_ms if custom_delay_ms is not None else self.send_delay_ms ) logger.info( "[scheduler] 延时 %dms 后处理下一个 (priority=%s)", delay, Priority(priority).name, ) await asyncio.sleep(delay / 1000.0) def get_metrics(self) -> dict: """返回调度器指标(供 /api/status 或 Prometheus 暴露)。 asyncio 单线程事件循环,与 worker 同线程,无需加锁。 """ return { **self._metrics, "pending_count": self._queue.qsize(), } async def drain(self, timeout: float = 5.0) -> bool: """等待队列清空(供 watchdog kill 微信前调用)。 通过 _queue.join() 等待所有已入队任务被 task_done。 worker 在 task_done 后可能仍在 sleep(delay),但队列已空, sleep 结束后阻塞在 get() 上,不会执行新任务。 边界条件: - worker 已停止时 join 会永远等待(无人调 task_done),靠 timeout 兜底 - drain 返回 True 后、kill 前若有新任务入队,worker 可能开始执行 (概率低,kill 后执行失败被正常捕获) Args: timeout: 最大等待秒数 Returns: True 表示队列已清空,False 表示超时未清空 """ try: await asyncio.wait_for(self._queue.join(), timeout=timeout) return True except asyncio.TimeoutError: logger.warning( "[scheduler] drain timeout %.1fs, %d tasks pending", timeout, self._queue.qsize(), ) return False ``` ### 3.2 优先级分配 | 调用点 | 优先级 | 理由 | |---|---|---| | `routes/login.py` wechat_restart | CRITICAL | 系统级紧急,需尽快执行 | | `routes/diagnostic.py` autofix_wechat_running | CRITICAL | 同上 | | `routes/login.py` logout | HIGH | 用户主动操作,感知延迟 | | `routes/login.py` qr_start (capture_qr_code) | HIGH | 登录流程关键步骤 | | `routes/contacts.py` accept_friend_request | HIGH | 好友申请有时效性 | | `friend_watcher._handle_request` | HIGH | 同上 | | `routes/send.py` send_text (flow + legacy) | NORMAL | 常规业务 | | `routes/send.py` send_file (flow + legacy) | NORMAL | 同上 | | `routes/send.py` revoke / forward | NORMAL | 同上 | | `routes/contacts.py` set_remark / add_friend | NORMAL | 同上 | | `routes/moments.py` publish / share / delete | NORMAL | 同上 | | `routes/moments.py` like / comment | LOW | 可延迟,不阻塞主流程 | | `routes/screenshot.py` capture_full_screenshot | LOW | 可延迟 | | `routes/login.py` qr_wait (detect_login_state 轮询) | **不入队** | 只读探测,保持现状 | | `LoginGuard._check_loop` | **不入队** | 只读探测,保持现状 | | `WeChatWatchdog._run` (pgrep/xdpyinfo) | **不入队** | 系统级探测,保持现状 | | `WeChatWatchdog._autofix_wechat` (kill) | **不入队但协同** | kill 前调 scheduler.drain() | ### 3.3 收口方案(6 个 P0 调用点) #### 3.3.1 routes/login.py - logout **关键**:logout 路由在执行 logout 前会先调 `detect_login_state`(只读探测,不入队)。仅 `xdotool.logout()` 入队。 ```python # 修改前(routes/login.py:201) if state_before == LoginState.LOGGED_IN.value: await xdotool.logout() # 直接调,不入队 # 修改后 if state_before == LoginState.LOGGED_IN.value: scheduler = _require_send_queue() await scheduler.execute( lambda: xdotool.logout(), priority=Priority.HIGH, wait_timeout_ms=30000, # logout 多步操作,给 30s trace_id="logout", ) ``` **说明**:`detect_login_state` 保持不入队(只读探测,与 LoginGuard 一致),仅 `xdotool.logout()` 入队。 #### 3.3.2 routes/login.py - wechat_restart ```python # 修改后(routes/login.py:281) scheduler = _require_send_queue() new_pid = await scheduler.execute( lambda: xdotool.restart_wechat(timeout_sec=timeout_sec), priority=Priority.CRITICAL, wait_timeout_ms=60000, # restart 可能慢,给 60s trace_id="wechat_restart", ) ``` #### 3.3.3 routes/login.py - qr_start ```python # 修改后(routes/login.py:58-59) scheduler = _require_send_queue() async def _qr_capture_flow(): await xdotool._activate_window_fast() return await qr_capture.capture_qr_code() qr_data_url = await scheduler.execute( _qr_capture_flow, priority=Priority.HIGH, wait_timeout_ms=15000, trace_id="qr_start", ) ``` #### 3.3.4 routes/screenshot.py ```python # 修改后(routes/screenshot.py:42) scheduler = _require_send_queue() png_bytes = await scheduler.execute( lambda: qr_capture.capture_full_screenshot(), priority=Priority.LOW, wait_timeout_ms=10000, trace_id="screenshot", ) ``` #### 3.3.5 routes/diagnostic.py - autofix_wechat_running ```python # 修改后(routes/diagnostic.py:230-249) scheduler = _require_send_queue() async def _autofix_flow(): # pkill + check_pid + start_wechat 组合 proc = await asyncio.create_subprocess_exec( "pkill", "-x", "wechat", stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, ) await proc.wait() await asyncio.sleep(2) # 等 autostart 拉起 new_pid = await xdotool.check_wechat_pid() if new_pid is None: new_pid = await xdotool.start_wechat(timeout_sec=10) return new_pid new_pid = await scheduler.execute( _autofix_flow, priority=Priority.CRITICAL, wait_timeout_ms=30000, trace_id="autofix_wechat_running", ) ``` #### 3.3.6 QrCapture 超时修复 ```python # ui/qr_capture.py 修改(L48-57) _CMD_TIMEOUT_SEC = 5.0 # 新增常量 async def _run(self, args: list[str]) -> tuple[int, bytes, bytes]: """执行一条命令并返回 (returncode, stdout, stderr)。""" proc = await asyncio.create_subprocess_exec( *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=self._env(), ) try: stdout, stderr = await asyncio.wait_for( proc.communicate(), timeout=_CMD_TIMEOUT_SEC ) return proc.returncode, stdout, stderr except asyncio.TimeoutError: proc.kill() await proc.wait() raise ``` #### 3.3.7 WeChatWatchdog 协同 ```python # ui/watchdog.py 修改 class WeChatWatchdog: def __init__( self, backend: BackendProtocol, interval: float = 10.0, fail_threshold: int = 2, scheduler=None, # 新增可选参数,向后兼容 ) -> None: self.backend = backend self.interval = interval self.fail_threshold = fail_threshold self._scheduler = scheduler # UIActionScheduler 实例(用于 drain) self._fail_count = 0 self._task: Optional[asyncio.Task] = None self._stopped = False async def _autofix_wechat(self) -> None: logger.warning("[watchdog] autofix: killing wechat for restart") # kill 前等待 UI 操作队列清空(避免 kill 正在执行的 UI 操作) if self._scheduler is not None: drained = await self._scheduler.drain(timeout=5.0) if not drained: logger.warning( "[watchdog] drain timeout, force kill with %d UI ops pending", self._scheduler.pending_count(), ) # 原有 kill 逻辑(pgrep + kill -TERM)保持不变 try: proc = await asyncio.create_subprocess_exec( "pgrep", "-x", "wechat", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) # ... 原有逻辑 ``` **app.py 注入**: ```python # app.py:499 修改 _state.watchdog = WeChatWatchdog( backend=_state.xdotool_backend, interval=10.0, scheduler=_state.send_queue, # 新增:注入调度器 ) ``` ### 3.4 注入与生命周期 ```python # app.py _init_state 修改(替换 L383-L387) from woc_bridge.messaging.ui_action_scheduler import UIActionScheduler, Priority _state.send_queue = UIActionScheduler( # 替换原 SendQueue send_delay_ms=cfg.send_delay_ms, max_calls_per_sec=cfg.max_calls_per_sec, max_queue_size=cfg.max_queue_size, ) # _state.send_queue 类型注解保持 SendQueue(多态,UIActionScheduler 是子类) # 现有 _require_send_queue() 返回 SendQueue 类型,调用方无需修改 # 调用方可用 hasattr(scheduler, 'execute') 判断是否支持优先级 # watchdog 注入 scheduler(app.py:499 修改) _state.watchdog = WeChatWatchdog( backend=_state.xdotool_backend, interval=10.0, scheduler=_state.send_queue, # 新增:注入调度器用于 drain ) ``` **生命周期不变**: - `send_queue.start()` / `send_queue.stop()` 继承父类,lifespan 中保持原顺序 - `watchdog.start()` / `watchdog.stop()` 保持原顺序 - shutdown 时 `verify_bus.clear()` 在 `send_queue.stop()` 之前(已有逻辑) ### 3.5 Metrics 暴露 #### 3.5.1 StatusResponse 模型扩展 ```python # bridge/woc_bridge/models/status.py 修改 class StatusResponse(BaseModel): # ... 现有字段保持不变 ... send_queue_pending: int = Field(default=0, description="发送队列积压任务数") # 新增字段(Optional,向后兼容) ui_scheduler: Optional[dict] = Field( default=None, description="UI 调度器指标(仅 UIActionScheduler 实例才有)", ) ``` #### 3.5.2 routes/status.py 暴露 metrics ```python # routes/status.py 修改(L96 附近) send_queue = _state.send_queue send_queue_pending = send_queue.pending_count() if send_queue else 0 ui_scheduler_metrics = None if send_queue is not None and hasattr(send_queue, 'get_metrics'): ui_scheduler_metrics = send_queue.get_metrics() return StatusResponse( # ... 现有字段 ... send_queue_pending=send_queue_pending, ui_scheduler=ui_scheduler_metrics, # ... ) ``` 返回示例: ```json { "send_queue_pending": 0, "ui_scheduler": { "executed_total": 1234, "executed_by_priority": { "CRITICAL": 2, "HIGH": 15, "NORMAL": 1200, "LOW": 17 }, "wait_duration_ms_sum": 45678.9, "exec_duration_ms_sum": 234567.8, "timeout_total": 3, "rate_limited_total": 1, "pending_count": 0 } } ``` #### 3.5.3 激活已存在的 Prometheus metrics **发现问题**:`ui/metrics.py:58` 已定义 `woc_send_queue_pending` Gauge,但**从未在任何地方 set 它的值**(已存在的 bug)。 ```python # ui/metrics.py 已有定义(无需修改) send_queue_pending = Gauge( "woc_send_queue_pending", "Send queue pending count", ) # 新增:在 routes/status.py 或 app.py 中激活 # 方案 A:在 status 接口中 set(每次查询时更新) from woc_bridge.ui.metrics import send_queue_pending as send_queue_pending_gauge if send_queue is not None: send_queue_pending_gauge.set(send_queue.pending_count()) # 方案 B:在 UIActionScheduler._run 中 set(每次出队时更新) # 更实时但会增加 metrics 写入频率 ``` **推荐方案 A**:在 status 接口中 set,与现有 `send_queue_pending` 字段同步更新,避免高频写入 Prometheus。 #### 3.5.4 新增 Prometheus metrics(可选,阶段 3) ```python # ui/metrics.py 新增 from prometheus_client import Counter, Gauge, Histogram ui_action_executed = Counter( "woc_ui_action_executed_total", "UI actions executed total", ["priority"], # CRITICAL / HIGH / NORMAL / LOW ) ui_action_wait_duration = Histogram( "woc_ui_action_wait_duration_seconds", "UI action wait duration (queue wait time)", buckets=(0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0), ) ui_action_exec_duration = Histogram( "woc_ui_action_exec_duration_seconds", "UI action execution duration", ["priority"], buckets=(0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0), ) ui_action_timeout = Counter( "woc_ui_action_timeout_total", "UI action wait timeout count", ) ui_action_rate_limited = Counter( "woc_ui_action_rate_limited_total", "UI action rate limited (queue full) count", ) ``` 在 `UIActionScheduler._run` 和 `_enqueue_with_priority` 中埋点。 --- ## 四、迁移路径 ### 阶段 1:基础设施(无破坏性) **目标**:引入 UIActionScheduler,向后兼容现有调用 **改动**: 1. 新增 `messaging/ui_action_scheduler.py`(Priority + UIActionScheduler 类) 2. `app.py` 将 `SendQueue(...)` 替换为 `UIActionScheduler(...)` 3. `watchdog.py` 增加 `scheduler` 参数(可选,向后兼容) 4. `routes/status.py` 暴露 metrics **验证**: - py_compile 通过 - 现有 16 个 `enqueue` 调用点无需修改,行为不变 - `/api/status` 返回 `ui_scheduler` 字段 ### 阶段 2:收口 P0 调用点 **目标**:消除 6 个绕过队列的竞态风险 + QrCapture 超时修复 **改动**: 1. `routes/login.py`:logout / restart / qr_start 改用 `scheduler.execute` - logout 仅 `xdotool.logout()` 入队,`detect_login_state` 保持不入队 2. `routes/screenshot.py`:改用 `scheduler.execute(priority=LOW)` 3. `routes/diagnostic.py`:autofix 改用 `scheduler.execute(priority=CRITICAL)` 4. `ui/qr_capture.py`:增加 `_CMD_TIMEOUT_SEC=5.0` 包裹 `proc.communicate()` 5. `ui/watchdog.py`:构造函数新增 `scheduler` 参数;`_autofix_wechat` 增加 `scheduler.drain()` 调用 6. `app.py`:`WeChatWatchdog(...)` 注入 `scheduler=_state.send_queue` **验证**: - py_compile 通过 - 并发调用 `POST /api/login/logout` + `POST /api/send/text` 不再竞态(logout 入队等待) - `POST /api/screenshot` 与 send_text 串行执行 - watchdog autofix 前等待 UI 队列清空(`drain` 返回 True 后再 kill) - QrCapture scrot 卡死时 5s 超时(不再无限阻塞) ### 阶段 3:可观测性增强(可选) **目标**:提供 Prometheus metrics 和详细日志 **改动**: 1. `models/status.py`:`StatusResponse` 新增 `ui_scheduler: Optional[dict]` 字段 2. `routes/status.py`:暴露 `ui_scheduler` metrics + 激活已存在的 `woc_send_queue_pending` Gauge 3. `ui/metrics.py`:新增 `woc_ui_action_*` 系列 metrics(Counter/Histogram) 4. `UIActionScheduler._run` / `_enqueue_with_priority`:埋点写入 Prometheus metrics 5. 日志增加 `priority` 和 `trace_id` 字段(已在阶段 1 代码中包含) **验证**: - `GET /api/status` 返回 `ui_scheduler` 字段 - `/metrics` 端点返回 `woc_ui_action_executed_total` 等指标 - 日志可按 `priority=CRITICAL` 过滤 - 已存在的 `woc_send_queue_pending` Gauge 不再为 0(bug 修复) --- ## 五、验收标准 ### AC-01:UIActionScheduler 向后兼容 **Given** 现有 16 个 `enqueue` 调用点未修改 **When** `app.py` 将 `SendQueue(...)` 替换为 `UIActionScheduler(...)` **Then** 所有现有功能行为不变,py_compile 通过,单元测试通过 ### AC-02:优先级调度生效 **Given** 队列中有 10 个 NORMAL 优先级的 send_text 任务 **When** 提交一个 CRITICAL 优先级的 wechat_restart **Then** wechat_restart 优先于剩余 NORMAL 任务执行(可能在当前 NORMAL 任务执行完后立即执行) ### AC-03:logout 与 send_text 不再竞态 **Given** 一个 send_text 正在 send_queue 中执行 **When** 并发调用 `POST /api/login/logout` **Then** logout 入队等待,send_text 执行完后 logout 才开始执行 ### AC-04:QrCapture 有超时保护 **Given** scrot 子进程卡死 **When** `QrCapture._run` 执行 **Then** 5 秒后超时,`proc.kill()` + `await proc.wait()`,抛出 TimeoutError ### AC-05:watchdog autofix 协同 **Given** send_queue 中有 5 个 pending 任务 **When** watchdog 触发 `_autofix_wechat` **Then** 先 `scheduler.drain(timeout=5.0)` 等待队列清空,超时则强制 kill 并记 warning ### AC-06:metrics 暴露 **Given** 调度器执行了若干操作 **When** 调用 `GET /api/status` **Then** 返回 `ui_scheduler` 字段,包含 `executed_total` / `executed_by_priority` / `pending_count` 等指标 ### 5.1 关键场景时序图 #### 5.1.1 修改前:logout 与 send_text 并发竞态 ``` 时刻 T0: send_text 正在执行(队列 worker 持有 coro_factory) ┌─────────────────────────────────────────────────────┐ Worker: │ send_text Flow: │ │ activate → click_search → type_query → ... │ │ ↑ 每步持 L1 _ui_lock,步骤间释放 │ └─────────────────────────────────────────────────────┘ 时刻 T1: HTTP 请求 POST /api/login/logout 到达 ↓ logout 路由直接调 xdotool.logout()(不入队) ↓ logout 尝试 acquire _ui_lock → 等待 send_text 释放 时刻 T2: send_text 执行 click_search_box 完成释放 _ui_lock ↓ logout 抢到 _ui_lock,执行 activate ↓ logout 释放 _ui_lock ↓ send_text 抢到 _ui_lock,执行 type_query ↓ ❌ 焦点已被 logout 改变,type_query 输入到错误位置! 时刻 T3: logout 再次抢 _ui_lock 执行 click_main_menu ↓ ❌ send_text 可能仍在执行,菜单状态不可预测 结果:send_text 发错会话,logout 失败,UI 状态混乱 ``` #### 5.1.2 修改后:logout 与 send_text 串行执行 ``` 时刻 T0: send_text 正在执行(worker 持有) ┌─────────────────────────────────────────────────────┐ Worker: │ send_text Flow (NORMAL priority) │ │ activate → click_search → type_query → ... │ └─────────────────────────────────────────────────────┘ 时刻 T1: HTTP 请求 POST /api/login/logout 到达 ↓ logout 路由调 scheduler.execute(logout, HIGH, 30s) ↓ 入队 (HIGH, seq=N),pending=1 ↓ worker 仍在执行 send_text,logout 等待 future 时刻 T2: send_text 完成,worker 出队 ↓ PriorityQueue 出队 (HIGH, N)(优先于其他 NORMAL 任务) ↓ 执行 xdotool.logout() 完整流程(多步操作不被穿插) ↓ logout 完成,返回 success 结果:send_text 与 logout 串行,UI 状态正确 ``` #### 5.1.3 watchdog kill 与 drain 协同 ``` 时刻 T0: send_queue 中有 5 个 pending send_text worker 正在执行第 1 个 send_text 时刻 T1: watchdog 检测到 wechat 不响应(fail_count >= 2) ↓ watchdog 调 _autofix_wechat ↓ scheduler.drain(timeout=5.0) ↓ 等待 _queue.join(),worker 继续执行当前任务 时刻 T2: worker 完成当前 send_text(task_done) ↓ _queue.join() 检查未完成任务数 ↓ 仍有 4 个 pending(task_done 计数未归零) ↓ worker 出队下一个 send_text 并执行... ↓ ⚠️ 若每个 send_text 耗时 > 1.25s,5s 内无法清空 时刻 T3a (5s 内清空): ↓ drain 返回 True ↓ scheduler.mark_wechat_dead(15.0) ← 启动 fast-fail 窗口 ↓ pgrep + kill -TERM wechat ↓ 后续 15s 内新入队任务直接 fast-fail(WECHAT_NOT_READY) 时刻 T3b (5s 超时未清空): ↓ drain 返回 False,记 warning ↓ scheduler.mark_wechat_dead(15.0) ↓ pgrep + kill -TERM wechat ↓ ❌ 仍有 N 个 pending 任务,kill 后每个都会失败一次 时刻 T4: autostart 拉起新 wechat 进程(~5-10s) ↓ mark_wechat_dead 窗口未到期,新任务继续 fast-fail ↓ 窗口到期后,新任务正常执行(wechat 已就绪) 结果:drain 减少失败任务数,mark_wechat_dead 避免失败风暴放大 ``` #### 5.1.4 优先级插队场景 ``` 时刻 T0: 队列状态:[send1(NORMAL,seq=1), send2(NORMAL,seq=2), ..., send10(NORMAL,seq=10)] worker 正在执行 send1 时刻 T1: HTTP 请求 POST /api/wechat/restart 到达 ↓ scheduler.execute(restart, CRITICAL, 0ms, 60s) ↓ 入队 (CRITICAL=0, seq=11) ↓ PriorityQueue 重排:[(0,11), (2,2), (2,3), ..., (2,10)] ↓ pending=10(restart 排第 1) 时刻 T2: send1 完成,worker 出队 ↓ 出队 (0, 11) → restart 任务 ↓ scheduler.mark_wechat_dead(8.0) ← 标记 fast-fail 窗口 ↓ 执行 restart_wechat (60s 超时) 时刻 T3: restart 完成(~5s) ↓ 后续 8s 内 (mark_wechat_dead 窗口): ↓ send2-send10 出队 → 检查 _wechat_dead_until → fast-fail ↓ 客户端收到 WECHAT_NOT_READY + retry_after=5 ↓ 8s 后窗口到期,新任务正常执行 结果:restart 在 1 个 send 完成后立即执行(不等 send2-send10), 后续 send 通过 fast-fail 快速失败,避免 9 次无谓的 xdotool 超时 ``` --- ## 六、风险与依赖 ### 6.1 技术风险 | 风险 | 概率 | 影响 | 缓解 | |---|---|---|---| | PriorityQueue 元组比较失败 | 低 | worker 崩溃 | `coro_factory` 不可比较,用 `seq` 序列号作为第二排序键,`(priority, seq)` 组合唯一,不会比较到 coro_factory | | 父类 `_run` 与子类 `_run` 元组结构不一致 | 中 | 父类被误调用时解包失败 | 子类必须覆盖 `_run`;代码注释明确标注"覆盖父类";单元测试验证子类 `_run` 被调用 | | 父类 `__init__` 创建 `asyncio.Queue` 后被子类 `asyncio.PriorityQueue` 覆盖 | 低 | 内存短暂多出一个未使用 Queue 对象 | Python 属性遮蔽合法,旧 Queue 被 GC 回收,无副作用 | | 优先级反转导致 HIGH 操作被 LOW 阻塞 | 中 | logout 等待 screenshot 完成 | 限制单次操作超时(已有 5s/30s),screenshot 入 LOW 但超时 10s | | drain 阻塞 watchdog | 中 | autofix 延迟 | drain 超时 5s 后强制 kill,不无限等待 | | drain 返回 True 后 worker 仍在 sleep | 低 | kill 时机略早于 sleep 结束 | 队列已空,worker sleep 结束后阻塞在 `get()` 上,不会执行新任务;kill 后 worker 执行失败被正常捕获 | | 收口后 logout 等 HTTP 接口延迟增加 | 中 | 用户感知 | priority=HIGH 保证优先级,wait_timeout_ms 兜底 | | `put_nowait` 与父类 `await put` 行为差异 | 低 | 满队时子类立即抛错,父类先检查再 put | 行为等价(父类也有前置 qsize 检查),子类更简洁 | | `task_done()` 调用次数 | 低 | 计数器异常 | cancelled 分支和 finally 分支互斥(continue 跳过 finally),每个 `get()` 对应一次 `task_done()`,与父类一致 | | metrics 并发读写 | 低 | 数据轻微不准 | asyncio 单线程事件循环,worker 与 HTTP handler 同线程,无需加锁;`asyncio.to_thread` 释放事件循环时不读写 `_metrics` | ### 6.2 依赖 - 依赖现有 `SendQueue` 实现稳定(已验证 16 个调用点) - 依赖 `_ui_lock` 作为底层互斥(保持不变) - 依赖 `asyncio.PriorityQueue`(Python 3.8+ 标准库,无外部依赖) ### 6.3 非目标 - **不实现优先级抢占**:xdotool 子进程不可中断,低优先级操作开始后必须等其完成 - **不实现优先级继承**:xdotool 子进程无法感知调用方优先级 - **不重命名 `SendQueue` 类**:保持向后兼容,UIActionScheduler 继承之 - **不修改 `_ui_lock`**:保持作为底层单命令互斥的第二道防线 - **不收口 LoginGuard / MessageStreamer**:它们是只读探测,无需入队 ### 6.4 失败风暴问题与 fast-fail 机制 **问题场景**:当 watchdog kill 微信或 `wechat_restart` 完成后,send_queue 中可能仍有 N 个已入队的 send_text 任务。这些任务会按顺序执行,每个都因 `WINDOW_NOT_FOUND` 失败一次(直到 autostart 拉起新进程)。 **影响估算**: - 100 个 send_text 任务排队,每个执行失败耗时 ~1s(含 5s xdotool 超时 + 异常处理) - 微信 autostart 拉起需 ~5-10s - 失败风暴持续:`min(N, autostart拉起前积压数) × 1s` ≈ 5-10s 内 5-10 个任务连续失败 - 客户端收到 5-10 个 503 错误,可能触发重试,进一步放大风暴 **fast-fail 机制设计**(**本方案可选实施,建议阶段 2 一并实施**): ```python class UIActionScheduler(SendQueue): def __init__(self, ...): super().__init__(...) # ... self._wechat_dead_until: float = 0.0 # 时间戳,在此之前所有任务直接 fast-fail def mark_wechat_dead(self, duration_sec: float = 10.0) -> None: """标记微信已死,期间所有新任务直接 fast-fail。 供 watchdog kill / wechat_restart 调用: - kill 前调 mark_wechat_dead(15.0),覆盖 autostart 拉起窗口 - restart 完成后调 mark_wechat_dead(5.0),覆盖新进程启动窗口 """ self._wechat_dead_until = time.monotonic() + duration_sec logger.warning( "[scheduler] mark_wechat_dead %.1fs, %d pending tasks will fast-fail", duration_sec, self._queue.qsize(), ) async def _run(self) -> None: while True: priority, seq, coro_factory, future, custom_delay_ms = await self._queue.get() # ... cancelled 检查 ... # fast-fail 检查:微信已死期间所有任务直接失败 if self._wechat_dead_until > time.monotonic(): logger.info( "[scheduler] fast-fail (wechat dead, %.1fs remaining, priority=%s)", self._wechat_dead_until - time.monotonic(), Priority(priority).name, ) if not future.done(): future.set_exception(BridgeError( code="WECHAT_NOT_READY", message="微信进程未就绪(重启中),请稍后重试", details={"retry_after": 5}, )) self._queue.task_done() continue # 不延时,立即处理下一个 # ... 正常执行流程 ... ``` **调用点**: - `watchdog._autofix_wechat` kill 前调 `scheduler.mark_wechat_dead(15.0)` - `routes/login.py wechat_restart` 开始时调 `scheduler.mark_wechat_dead(8.0)` - `routes/diagnostic.py autofix_wechat_running` 同上 **注意事项**: - `WECHAT_NOT_READY` 是新错误码,需在 `models/errors.py` 注册 HTTP 503 映射 - fast-fail 任务不计入 `executed_total`,应单独记 `fast_fail_total` 指标 - CRITICAL 任务(如 restart 本身)不应被 fast-fail 拦截,需在检查中排除: ```python if self._wechat_dead_until > time.monotonic() and priority > Priority.CRITICAL: ``` **替代方案对比**: | 方案 | 优点 | 缺点 | |---|---|---| | fast-fail(推荐) | 立即拒绝,客户端快速收到错误并退避 | 需新增错误码 + 调用点埋点 | | drain + cancel pending | 队列清空,无失败任务 | cancel 已入队 future 复杂,可能误取消正在执行的任务 | | 不处理(保持现状) | 实现简单 | 5-10 个连续失败,客户端可能重试放大风暴 | --- ## 七、变更记录 | 版本 | 日期 | 修改人 | 摘要 | |---|---|---|---| | v1.0 | 2026-07-17 | - | 初稿,基于 SendQueue 调研设计 UIActionScheduler 统一调度方案 | | v1.1 | 2026-07-17 | - | 深度复核:修正 task_done 互斥说明、put_nowait 语义、logout 前置 detect_login_state 处理、StatusResponse 模型扩展、激活已存在 Prometheus Gauge、drain 边界条件、metrics 线程安全 | | v1.2 | 2026-07-18 | - | 深度调研优化:新增 1.4 双层保护模型(L1 _ui_lock vs L2 SendQueue)、1.5 调用点全景图(before/after)、1.6 HTTP 端点→优先级映射表、2.3.1 delay_ms 与优先级交互策略、5.1 关键场景时序图(4 个)、6.4 失败风暴问题与 fast-fail 机制(mark_wechat_dead)、UIActionScheduler 代码新增 mark_wechat_dead 方法与 _run fast-fail 检查、P1.1 detect_login_state 14 处散落调用分析与处理方案 | --- ## 八、实施前验证清单 实施前需确认以下事项,避免引入新问题: ### 8.1 代码复核清单 - [ ] `asyncio.PriorityQueue.put_nowait` 满时抛 `asyncio.QueueFull`(已确认,与 `asyncio.Queue` 行为一致) - [ ] `asyncio.PriorityQueue` 的 `task_done()` / `join()` 语义与 `asyncio.Queue` 一致(已确认,继承关系) - [ ] 元组 `(int, int, coro_factory, future, delay_ms)` 中 `seq` 单调递增保证唯一,不会比较到 `coro_factory`(已确认) - [ ] 父类 `__init__` 创建的 `asyncio.Queue` 会被子类 `asyncio.PriorityQueue` 覆盖,旧对象无其他引用,GC 回收(已确认) - [ ] 父类 `_run` 被子类覆盖,不会调用父类的 3 元组解包(已确认,代码注释标注) - [ ] `_check_rate_limit` 继承父类,行为不变(已确认,子类不覆盖) - [ ] `send_queue.start()` / `stop()` 继承父类,worker task 管理 不变(已确认) - [ ] `pending_count()` 继承父类,返回 `self._queue.qsize()`(已确认,PriorityQueue 也有 qsize) ### 8.2 单元测试清单 实施时需编写以下单元测试(参考 `verify_bus.py` 的测试模式): ```python # test_ui_action_scheduler.py async def test_backward_compat_enqueue(): """enqueue 方法向后兼容,priority 默认 NORMAL。""" scheduler = UIActionScheduler(send_delay_ms=10, max_calls_per_sec=100) await scheduler.start() try: result = await scheduler.enqueue(lambda: asyncio.sleep(0.01, result="ok")) assert result == "ok" metrics = scheduler.get_metrics() assert metrics["executed_total"] == 1 assert metrics["executed_by_priority"]["NORMAL"] == 1 finally: await scheduler.stop() async def test_priority_ordering(): """CRITICAL 优先于 NORMAL 执行。""" scheduler = UIActionScheduler(send_delay_ms=0, max_calls_per_sec=100) await scheduler.start() try: # 先入队一个 NORMAL(会立即开始执行) normal_future = scheduler.enqueue(lambda: asyncio.sleep(0.1, result="normal")) # 再入队 CRITICAL 和 NORMAL critical_future = scheduler.execute( lambda: asyncio.sleep(0.01, result="critical"), priority=Priority.CRITICAL, ) normal2_future = scheduler.execute( lambda: asyncio.sleep(0.01, result="normal2"), priority=Priority.NORMAL, ) # CRITICAL 应先于 normal2 完成 critical_result = await critical_future normal2_result = await normal2_future assert critical_result == "critical" assert normal2_result == "normal2" # 验证执行顺序:CRITICAL 在 normal2 之前 assert scheduler.get_metrics()["executed_by_priority"]["CRITICAL"] == 1 finally: await scheduler.stop() async def test_drain_empty_queue(): """空队列 drain 立即返回 True。""" scheduler = UIActionScheduler() result = await scheduler.drain(timeout=1.0) assert result is True async def test_drain_with_pending(): """有 pending 任务时 drain 等待完成。""" scheduler = UIActionScheduler(send_delay_ms=100) await scheduler.start() try: # 入队一个任务 await scheduler.enqueue(lambda: asyncio.sleep(0.05, result="ok")) # drain 等待完成 result = await scheduler.drain(timeout=2.0) assert result is True finally: await scheduler.stop() async def test_drain_timeout(): """任务执行超过 drain timeout 时返回 False。""" scheduler = UIActionScheduler(send_delay_ms=0) await scheduler.start() try: # 入队一个长任务 scheduler.enqueue(lambda: asyncio.sleep(1.0, result="slow")) # drain 短超时 result = await scheduler.drain(timeout=0.1) assert result is False finally: await scheduler.stop() async def test_queue_full_rate_limited(): """队列满时抛 RATE_LIMITED。""" scheduler = UIActionScheduler(send_delay_ms=1000, max_queue_size=2) await scheduler.start() try: # 填满队列(1 个执行中 + 2 个排队) scheduler.enqueue(lambda: asyncio.sleep(0.5)) scheduler.enqueue(lambda: asyncio.sleep(0.01)) scheduler.enqueue(lambda: asyncio.sleep(0.01)) # 第 4 个应被拒绝 with pytest.raises(BridgeError) as exc_info: await scheduler.enqueue(lambda: asyncio.sleep(0.01)) assert exc_info.value.code == "RATE_LIMITED" finally: await scheduler.stop() async def test_wait_timeout(): """等待超时抛 TIMEOUT。""" scheduler = UIActionScheduler(send_delay_ms=1000, max_calls_per_sec=100) await scheduler.start() try: # 第一个任务慢 scheduler.enqueue(lambda: asyncio.sleep(0.5)) # 第二个任务等待超时 with pytest.raises(BridgeError) as exc_info: await scheduler.enqueue( lambda: asyncio.sleep(0.01), wait_timeout_ms=100, # 100ms 超时 ) assert exc_info.value.code == "TIMEOUT" finally: await scheduler.stop() async def test_metrics_accuracy(): """metrics 准确记录执行次数和优先级。""" scheduler = UIActionScheduler(send_delay_ms=0, max_calls_per_sec=100) await scheduler.start() try: await scheduler.execute(lambda: "a", priority=Priority.CRITICAL) await scheduler.execute(lambda: "b", priority=Priority.HIGH) await scheduler.execute(lambda: "c", priority=Priority.NORMAL) await scheduler.execute(lambda: "d", priority=Priority.LOW) metrics = scheduler.get_metrics() assert metrics["executed_total"] == 4 assert metrics["executed_by_priority"]["CRITICAL"] == 1 assert metrics["executed_by_priority"]["HIGH"] == 1 assert metrics["executed_by_priority"]["NORMAL"] == 1 assert metrics["executed_by_priority"]["LOW"] == 1 assert metrics["pending_count"] == 0 finally: await scheduler.stop() ``` ### 8.3 集成测试清单 阶段 2 实施后需进行集成测试: - [ ] 并发 30 个 `POST /api/send/text` + 1 个 `POST /api/login/logout`:logout 在所有 send 之前或之后执行,不穿插 - [ ] `POST /api/screenshot` 与 `POST /api/send/text` 并发:screenshot 串行等待 - [ ] watchdog autofix 触发时 send_queue 有 pending:先 drain 再 kill - [ ] `GET /api/status` 返回 `ui_scheduler` 字段且 `executed_by_priority` 计数正确 - [ ] `/metrics` 端点 `woc_send_queue_pending` Gauge 不再恒为 0 ### 8.4 回归测试清单 阶段 1 实施后需验证现有功能不退化: - [ ] `POST /api/send/text` 正常发送(16 个 enqueue 调用点之一) - [ ] `POST /api/friends/accept` 正常通过好友(friend_watcher + routes/contacts 共用 enqueue) - [ ] `POST /api/moments/publish` 正常发朋友圈(routes/moments enqueue) - [ ] BatchWorker 群发正常串行(间接经 orchestrator → enqueue) - [ ] `GET /api/status` 的 `send_queue_pending` 字段仍正确