修复急停冻结/消息hash跳过/正常调度冷却误触三处问题

1. 急停冻结: gui.py 将 monitor.stop() 移到后台线程执行,避免 terminate+wait 阻塞 GUI 主线程 6~8s 触发 Windows 冻结弹窗;窗口关闭同样后台 stop。
2. 消息丢失: 仅当上一帧 OCR 已成功入队才允许相同 hash 帧跳过;新增 _is_question_message 提问豁免,使继承极老时间戳的明确提问(如'资源包是强制的,还是可选的?')不再被误判为远古历史永久丢弃。
3. 正常调度冷却: 正常资源释放(每N次调用杀进程)清除残留 30s 冷却并重置失败计数器;正常重启握手阶段的初始化超时/崩溃/协议/握手错误不再施加 30s 冷静期,交由系统自行调度启停。
This commit is contained in:
youpaishidifu
2026-08-06 20:32:50 +08:00
parent 59505fdafd
commit ef422ef776
2 changed files with 107 additions and 24 deletions
+22 -4
View File
@@ -435,15 +435,33 @@ class MsgBotApp(ctk.CTk):
# 安全更新 toggle 状态(主线程 after 调度)
self.after(0, self._update_stop_ui)
def _stop_monitor_async(self):
"""后台线程执行 monitor.stop(),避免阻塞 GUI 主线程触发 Windows 冻结弹窗。
stop() 内含多个线程 join + _kill_easyocr_proc(terminate+wait 最多 6s),
同步在 GUI 主线程执行会冻结 6~8s,触发系统"软件无响应"弹窗。
"""
try:
self.monitor.stop()
except Exception as e:
logger.warning(f"[gui] 后台 stop 异常: {e}")
def _update_stop_ui(self):
"""主线程更新急停后的 UI。"""
self.monitor.stop()
"""主线程更新急停后的 UI(stop 放到后台线程,主线程不冻结)。"""
self._toggle_btn.deselect()
self._status_label.configure(text="⏸ 急停")
self._log_queue.put("🔄 可点击「启用监控」重新启动")
# ⭐ UI 立即更新;stop() 在后台线程执行,主线程保持响应,不再触发冻结弹窗。
threading.Thread(target=self._stop_monitor_async, daemon=True, name="gui-stop").start()
# ── 窗口关闭 ──
def _on_close(self):
self.monitor.stop()
self.destroy()
# ⭐ 关闭窗口同样把 stop 放到后台线程,完成后再关闭,避免主线程冻结。
def _stop_then_close():
try:
self.monitor.stop()
except Exception:
pass
self.after(0, self.destroy)
threading.Thread(target=_stop_then_close, daemon=True, name="gui-stop-close").start()
+85 -20
View File
@@ -409,6 +409,10 @@ class MessageMonitor:
self._last_screenshot_hash: dict = {} # platform → str
self._ocr_unchanged: dict = {} # platform → bool(本轮因截图不变跳过)
self._last_ocr_time: dict = {} # platform → float(上次真正做OCR的时间戳)
# ⭐ hash 跳过鲁棒性:上一帧"已捕捉且已入队"才允许后续相同 hash 帧跳过。
# 若上一帧 OCR 捕捉到但未被入队(被时间戳/噪声过滤),则不跳过,
# 让后续相同 hash 帧重新做 OCR,避免因永久跳过导致消息丢失。
self._last_hash_queued: dict = {} # platform → bool(上一帧 OCR 是否产出可处理消息)
# OCR 截屏定时清理:保存后不再立即删(高频 create+delete 易触发 360 误报),
# 改由 _maybe_clean_ocr_dir 每 30~60 秒随机间隔清理一次过期截图。
@@ -479,6 +483,7 @@ class MessageMonitor:
self._last_screenshot_hash.clear()
self._ocr_unchanged.clear()
self._last_ocr_time.clear()
self._last_hash_queued.clear() # ⭐ 重置 hash 跳过鲁棒性标志
self._ocr_garbage_count.clear()
self._ocr_last_text.clear()
self._last_chosen_engine.clear()
@@ -778,6 +783,7 @@ class MessageMonitor:
self._last_screenshot_hash.clear()
self._ocr_unchanged.clear()
self._last_ocr_time.clear()
self._last_hash_queued.clear() # ⭐ 重置 hash 跳过鲁棒性标志
self._ocr_garbage_count.clear()
self._ocr_last_text.clear()
self._last_chosen_engine.clear()
@@ -1739,9 +1745,11 @@ class MessageMonitor:
try:
line = _q.get(timeout=60)
except _QueueEmpty:
logger.error("[ocr] EasyOCR 子进程初始化超时(60s),杀死进程,30s 后重试")
logger.error("[ocr] EasyOCR 子进程初始化超时(60s),杀死进程"
+ ("(正常重启,不施加冷却)" if is_normal_restart else ",30s 后重试"))
self._kill_easyocr_proc("初始化超时")
self._easyocr_retry_until = time.time() + 30
if not is_normal_restart:
self._easyocr_retry_until = time.time() + 30
return False
if not line:
# stdout 关闭 = worker 崩溃,读 stderr + 崩溃日志看原因
@@ -1762,11 +1770,14 @@ class MessageMonitor:
# ⭐ 检测页面文件不足(WinError 1455)→ 长期冷却,不反复重试
_is_pagefile_error = "页面文件" in str(detail) or "1455" in str(detail)
if _is_pagefile_error:
self._easyocr_retry_until = time.time() + 3600 # 1小时冷却(系统级问题)
logger.error("[ocr] ⚠️ 页面文件不足(WinError 1455),EasyOCR 冷却1小时。"
"建议:增大 Windows 页面文件大小(系统属性→高级→性能→虚拟内存)")
if not is_normal_restart:
self._easyocr_retry_until = time.time() + 3600 # 1小时冷却(系统级问题)
logger.error("[ocr] ⚠️ 页面文件不足(WinError 1455),EasyOCR"
+ ("正常重启不施加冷却,交由系统调度" if is_normal_restart else "冷却1小时。")
+ "建议:增大 Windows 页面文件大小(系统属性→高级→性能→虚拟内存)")
else:
self._easyocr_retry_until = time.time() + 30
if not is_normal_restart:
self._easyocr_retry_until = time.time() + 30
self._easyocr_proc = None
return False
try:
@@ -1782,12 +1793,14 @@ class MessageMonitor:
# 非 ready 响应 → 协议异常
logger.error(f"[ocr] EasyOCR worker 异常响应: {line[:100]!r}")
self._kill_easyocr_proc("协议异常")
self._easyocr_retry_until = time.time() + 30
if not is_normal_restart:
self._easyocr_retry_until = time.time() + 30
return False
except Exception as e:
logger.error(f"[ocr] EasyOCR 握手失败: {e}")
self._kill_easyocr_proc("握手失败")
self._easyocr_retry_until = time.time() + 30
if not is_normal_restart:
self._easyocr_retry_until = time.time() + 30
return False
def _call_easyocr_safe(self, img_np, timeout: Optional[float] = None) -> Optional[str]:
@@ -1907,6 +1920,11 @@ class MessageMonitor:
self._easyocr_call_count = 0
# ⭐ 正常调度重启:标记为正常重启,跳过内存检测直接启动
self._easyocr_normal_restart = True
# ⭐ 正常调度是系统自主管控的启停,不录入任何错误计数器、不施加冷静期:
# 清除可能残留的 30s 冷却,重置连续失败计数,交由系统自行调度启停。
self._easyocr_retry_until = 0.0
self._easyocr_consecutive_failures = 0
self._easyocr_consecutive_startup_failures = 0
return result["text"]
else:
err = result.get('error', '?')[:100]
@@ -2376,9 +2394,12 @@ class MessageMonitor:
now = time.time()
last_hash = self._last_screenshot_hash.get("qq", "")
last_time = self._last_ocr_time.get("qq", 0)
if h == last_hash and (now - last_time) < 5:
# ⭐ 仅当上一帧"已捕捉且已入队"才跳过:若上一帧 OCR 了但没入队
# (被时间戳/噪声过滤丢弃),则不跳过,给后续相同截图重新 OCR 的机会,
# 避免"捕捉到但没入队"的消息被永久跳过。
if h == last_hash and (now - last_time) < 5 and self._last_hash_queued.get("qq", False):
self._ocr_unchanged["qq"] = True
logger.debug(f"[qq] 截图 hash 相同,屏幕无变化,跳过本轮")
logger.debug(f"[qq] 截图 hash 相同且上一帧已入队,屏幕无变化,跳过本轮")
return []
self._last_screenshot_hash["qq"] = h
self._last_ocr_time["qq"] = now
@@ -2582,9 +2603,10 @@ class MessageMonitor:
now = time.time()
last_hash = self._last_screenshot_hash.get("wechat", "")
last_time = self._last_ocr_time.get("wechat", 0)
if h == last_hash and (now - last_time) < 5:
# ⭐ 仅当上一帧"已捕捉且已入队"才跳过(同 QQ 逻辑,防消息永久丢失)
if h == last_hash and (now - last_time) < 5 and self._last_hash_queued.get("wechat", False):
self._ocr_unchanged["wechat"] = True
logger.debug(f"[wechat] 截图 hash 相同,屏幕无变化,跳过本轮")
logger.debug(f"[wechat] 截图 hash 相同且上一帧已入队,屏幕无变化,跳过本轮")
return []
self._last_screenshot_hash["wechat"] = h
self._last_ocr_time["wechat"] = now
@@ -2765,10 +2787,16 @@ class MessageMonitor:
# 不再按年龄丢弃,交由下游 seen 去重;仅当继承时间戳极老(>2h)且
# 内容无新鲜特征时视为"翻看远古历史"丢弃,避免翻看旧记录被误回复。
if self._is_timestamp_old(last_ts, 7200) and not _fresh:
logger.debug(f"[ocr] stage[{i}] 继承时间戳{last_ts}极老(>2h)且非新鲜内容,视为远古历史跳过: {buf.strip()[:60]!r}")
buf = ''
last_ts = part
continue
# ⭐ 提问豁免:消息本身是明确提问(以?/?结尾、含中文、非md/噪声),
# 即使继承的时间戳极老(来自截图内其他内容如历史/文档时间戳),
# 也应放行交由下游去重,避免把用户刚发的提问误判为远古历史永久丢弃。
if self._is_question_message(buf.strip()):
logger.debug(f"[ocr] stage[{i}] 继承时间戳{last_ts}极老但消息为明确提问,放行交由下游去重: {buf.strip()[:60]!r}")
else:
logger.debug(f"[ocr] stage[{i}] 继承时间戳{last_ts}极老(>2h)且非新鲜内容,视为远古历史跳过: {buf.strip()[:60]!r}")
buf = ''
last_ts = part
continue
logger.debug(f"[ocr] stage[{i}] 继承时间戳{last_ts}较老但消息无独立时间戳,放行交由下游去重: {buf.strip()[:60]!r}")
if self._is_markdown_document(buf.strip()):
logger.debug(f"[ocr] stage[{i}] md文档直接跳过: {buf.strip()[:80]!r}")
@@ -2799,12 +2827,16 @@ class MessageMonitor:
logger.debug(f"[ocr] stage[{i}] 尾部无时间戳归属,交由下游去重: {buf.strip()[:60]!r}")
else:
_fresh_tail = self._looks_fresh_content(buf.strip())
if last_ts and self._is_timestamp_old(last_ts, 300):
if self._is_timestamp_old(last_ts, 7200) and not _fresh_tail:
if last_ts and self._is_timestamp_old(last_ts, 300):
if self._is_timestamp_old(last_ts, 7200) and not _fresh_tail:
# ⭐ 提问豁免(同中段逻辑):明确提问即使是尾部、继承极老时间戳也放行
if self._is_question_message(buf.strip()):
logger.debug(f"[ocr] stage[{i}] 尾部时间戳{last_ts}极老但消息为明确提问,放行交由下游去重: {buf.strip()[:60]!r}")
else:
logger.debug(f"[ocr] stage[{i}] 尾部时间戳{last_ts}极老(>2h)且非新鲜内容,视为远古历史跳过尾部: {buf.strip()[:60]!r}")
buf = ''
else:
logger.debug(f"[ocr] stage[{i}] 尾部时间戳{last_ts}较老但消息无独立时间戳,放行交由下游去重: {buf.strip()[:60]!r}")
else:
logger.debug(f"[ocr] stage[{i}] 尾部时间戳{last_ts}较老但消息无独立时间戳,放行交由下游去重: {buf.strip()[:60]!r}")
if buf.strip():
if self._is_markdown_document(buf.strip()):
logger.debug(f"[ocr] stage[{i}] md文档直接跳过(尾部): {buf.strip()[:80]!r}")
@@ -3884,6 +3916,27 @@ class MessageMonitor:
return True
return False
def _is_question_message(self, text: str) -> bool:
"""判断一段文本是否为"明确提问",用于 stage 远古历史过滤的提问豁免。
仅当文本以问号(?/?)结尾、含中文、且不是 md 文档/噪声文本时才判定为提问。
作用:截图内某条消息继承了来自其他内容(历史记录、文档)的极老时间戳,
但该消息本身是用户刚发的提问(如"A还是B?"型选择问句,不含怎么/如何等疑问词),
不应因继承时间戳极老被误判为远古历史永久丢弃,应放行交由下游去重处理。
"""
if not text or not text.strip():
return False
t = text.strip()
if not t.endswith(('?', '?')):
return False
if not re.search(r'[\u4e00-\u9fff]', t):
return False
if self._is_markdown_document(t):
return False
if self._is_noise_text(t):
return False
return True
def _block_has_cjk_message(self, normalized_text: str) -> bool:
"""判断整块 OCR 文本去掉时间戳/噪声后是否仍含中文消息内容。
@@ -5360,6 +5413,18 @@ class MessageMonitor:
# 3. 读消息(Windows OCR 同步阻塞,可能耗时 2-5s)
texts = self._read_messages(win, platform)
# ⭐ hash 跳过鲁棒性:记录本轮 OCR 是否产出可处理消息。
# - 被 hash 跳过(_ocr_unchanged)→ 不动标志(沿用上一帧结果)
# - OCR 产出消息 → 标记已入队,允许后续相同 hash 帧跳过
# - OCR 运行但无消息(被过滤)→ 标记未入队,后续相同 hash 帧重新 OCR,
# 避免"捕捉到但没入队"的消息被永久跳过。
if self._ocr_unchanged.get(platform):
pass
elif texts:
self._last_hash_queued[platform] = True
else:
self._last_hash_queued[platform] = False
# ⚠️ OCR 返回后立即检查急停标志:急停可能在 OCR 阻塞期间触发,
# Windows OCR 无法被中断,但后续文本处理可以立即跳过。
if not self._running: