fix(msgbot): 修复OCR空列表IndexError并增强stop内存释放至最低占用

Task #1622: 行合并循环在 merged_lines 为空时无条件访问 merged_lines[-1]
抛 IndexError,被最外层 except 记为 [qq] OCR失败: list index out of range。
增加 `if merged_lines else False` 守卫,首行非@结尾/不含问号/非bot回复时
不再越界。

Task #1623: 原 _release_memory 仅 SetProcessWorkingSetSize(-1,-1) 不逼出常驻
页,且未释放 winrt Windows OCR 引擎、未清 GPU 缓存,导致 bot 运行后暂停
内存仍高达~600MB。增强为 EmptyWorkingSet 强制归还物理内存 +
torch.cuda.empty_cache/synchronize + 释放 self._ocr_engine 引用,与 GUI 侧
_force_system_memory_cleanup 范式一致,使运行后暂停与刚启动待机内存一致。
This commit is contained in:
youpaishidifu
2026-08-29 21:55:59 +08:00
parent 6b30101806
commit 781a578412
+69 -27
View File
@@ -1020,47 +1020,86 @@ class MessageMonitor:
return self._stop_event.wait(timeout=seconds)
def _release_memory(self):
"""主动释放内存,交还系统重新分配调度。只保留GUI需要的基本内存。"""
"""主动释放内存,交还系统重新分配调度。只保留GUI需要的基本内存。
⭐ Task #1623: 增强版——除原有 SetProcessWorkingSetSize 外,新增:
1. psapi.EmptyWorkingSet 强制把工作集页面归还 OS(SetProcessWorkingSetSize(-1,-1)
只会回收"已 eligible"的页面,无法把 OCR 引擎/图像缓存等常驻页逼出内存,
故 600MB 残留)。
2. torch.cuda.empty_cache() + synchronize() 归还 GPU 显存。
3. 释放主进程内 Windows OCR 引擎 self._ocr_engine(winrt 原生对象,stop 时
不释放会长期占用数百 MB)。
目标:待机(刚启动 / bot 运行一会后暂停)内存都只保留最低所需。
"""
import gc
import ctypes
import sys
# 强制垃圾回收
# 1) 先释放 Python 侧大对象引用,再回收,避免 IMAGE/torch tensor 被环引用困住
# 清空大对象引用
for attr in ['_last_texts', '_seen_messages', '_replied_texts']:
if hasattr(self, attr):
getattr(self, attr).clear()
if hasattr(self, '_semantic_fingerprint_cache'):
self._semantic_fingerprint_cache.clear()
# ⭐ Task #1623: 释放 Windows OCR 引擎(winrt 原生对象)
if getattr(self, '_ocr_engine', None) is not None:
try:
del self._ocr_engine
except Exception:
pass
self._ocr_engine = None
logger.debug("[memory] 已释放 Windows OCR 引擎引用")
# 清空 EasyOCR 相关缓存
if hasattr(self, '_easyocr_proc') and self._easyocr_proc:
# 子进程已杀,但引用可能残留
self._easyocr_proc = None
# 强制垃圾回收(释放上述被清引用占据的对象)
gc.collect()
# Windows 特定:使用 EmptyProcessWorkingSet 释放工作集内存
# 2) Windows 特定:把工作集物理内存归还 OS
try:
# 获取当前进程句柄
kernel32 = ctypes.windll.kernel32
psapi = ctypes.windll.psapi
# EmptyProcessWorkingSet 函数:将进程工作集最小化
# 参数:进程句柄,最小值(-1表示当前进程),最大值(-1表示当前进程)
# 返回:非零表示成功
h_process = kernel32.GetCurrentProcess()
# EmptyWorkingSet:强制把进程工作集所有页面换出到页面文件/丢弃,
# 是真正把物理 RAM 交还 OS 的调用(SetProcessWorkingSetSize 仅设上下限)。
try:
if psapi.EmptyWorkingSet(h_process):
logger.debug("[memory] 已调用 EmptyWorkingSet 归还物理内存")
else:
logger.debug("[memory] EmptyWorkingSet 返回 0(无需归还)")
except Exception as e:
logger.debug(f"[memory] EmptyWorkingSet 失败: {e}")
# SetProcessWorkingSetSize(-1,-1):允许系统后续按需在最小/最大间调度
kernel32.SetProcessWorkingSetSize(
kernel32.GetCurrentProcess(),
h_process,
-1, # 最小工作集:允许系统回收所有可回收页面
-1 # 最大工作集:允许系统回收所有可回收页面
)
logger.debug("[memory] 已调用 SetProcessWorkingSetSize 释放工作集内存")
except Exception as e:
logger.debug(f"[memory] SetProcessWorkingSetSize 失败: {e}")
# 清空 EasyOCR 相关缓存
if hasattr(self, '_easyocr_proc') and self._easyocr_proc:
# 子进程已杀,但引用可能残留
self._easyocr_proc = None
# 清空大对象引用
for attr in ['_last_texts', '_seen_messages', '_replied_texts']:
if hasattr(self, attr):
getattr(self, attr).clear()
if hasattr(self, '_semantic_fingerprint_cache'):
self._semantic_fingerprint_cache.clear()
# 再次垃圾回收
# 3) 归还 GPU 显存(若 torch/CUDA 可用)
try:
import torch
if torch.cuda.is_available():
torch.cuda.synchronize()
torch.cuda.empty_cache()
logger.debug("[memory] 已调用 torch.cuda.empty_cache 归还显存")
except Exception:
# torch 未安装或当前为 CPU 推理,忽略
pass
# 再次垃圾回收,确保上述释放后的垃圾被最终回收
gc.collect()
logger.info("[memory] 内存释放完成,已交还系统")
def _kill_easyocr_proc(self, reason: str = ""):
@@ -3619,7 +3658,10 @@ class MessageMonitor:
or re.match(r'^[\u4e00-\u9fff]{2,8}[@,,]', _next_line) \
or re.match(r'^[/@((]', _next_line):
_next_is_short_or_name = True
_prev_ends_with_comma = merged_lines[-1].rstrip().endswith((',', ','))
# ⭐ Task #1622: 必须先判断 merged_lines 非空,否则首行不以@结尾/不含问号/
# 非bot回复时 merged_lines[-1] 抛 IndexError → 被最外层 except 捕获成
# [qq] OCR失败: list index out of range
_prev_ends_with_comma = merged_lines[-1].rstrip().endswith((',', ',')) if merged_lines else False
if merged_lines and not _SENT_END.search(merged_lines[-1]) \
and not _TS_START.match(rl) and not _TS_START.match(merged_lines[-1]) \
and not _is_bot_reply_line and not _is_single_excl and not _prev_is_single_excl \