修复急停冻结/消息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:
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user