fix(easyocr): 顶层崩溃全程留痕,消除"一声不吭"结束进程

- easyocr_worker.py: 新增 worker 状态文件 easyocr_worker_state.json
  (pid/mode/status/last_img_len/ts/error),就绪/忙碌/空闲实时落盘;
  强化 sys.excepthook / _flush_exit / _signal_handler 崩溃写状态+crash.log
- monitor.py: _kill_easyocr_proc 杀前诊断三路
  (状态文件 + 残留stderr + crash.log尾),以 warning 汇总进主日志;
  _read_easyocr_crash_tail 新增 ignore_age 跳过60s时间窗用于诊断路径
- unicode_radicals.md: 丿(U+4E3F)行改 - 不参与偏旁过滤,修复OCR碎片误杀儿下载等
This commit is contained in:
youpaishidifu
2026-08-06 22:55:29 +08:00
parent 3df59c53ad
commit 9944d74798
3 changed files with 153 additions and 8 deletions
+48 -1
View File
@@ -58,6 +58,44 @@ def _get_crash_log_path() -> str:
CRASH_LOG = _get_crash_log_path()
# ── 状态文件:记录 worker 当前在处理什么/卡多久,父进程 kill 前读取,避免"一声不吭" ──
def _get_state_path() -> str:
try:
base = os.path.dirname(os.path.abspath(__file__))
except Exception:
base = "."
return os.path.join(base, "easyocr_worker_state.json")
STATE_PATH = _get_state_path()
# worker 当前运行模式(GPU/CPU),由 _init_reader 设置
_WORKER_MODE = "未知"
def _write_state(status: str, last_img_len: int = 0, ts: float = None, error: str = ""):
"""把当前 worker 状态落盘为 JSON,便于父进程 kill 前诊断死前状态。
status: idle / busy / crashed / terminated
"""
try:
_ts = ts if ts is not None else datetime.now().timestamp()
_rec = {
"pid": os.getpid(),
"mode": _WORKER_MODE,
"status": status,
"last_img_len": last_img_len,
"ts": _ts,
"iso": datetime.fromtimestamp(_ts).isoformat() if _ts else "",
"error": error,
}
with open(STATE_PATH, "w", encoding="utf-8") as f:
json.dump(_rec, f, ensure_ascii=False)
f.flush()
except Exception:
pass
def _write_crash_log(msg: str):
try:
with open(CRASH_LOG, "a", encoding="utf-8") as f:
@@ -72,6 +110,7 @@ def _flush_exit(msg, code=1):
sys.stderr.write(full + "\n")
sys.stderr.flush()
_write_crash_log(f"{datetime.now()} {full}")
_write_state("crashed", error=full[:500])
sys.exit(code)
# ── 全局未捕获异常钩子:任何漏网的 Python 异常都落盘 ──
@@ -80,6 +119,7 @@ def _global_excepthook(exc_type, exc_val, exc_tb):
sys.stderr.write(f"[easyocr_worker] 未捕获异常:\n{msg}")
sys.stderr.flush()
_write_crash_log(f"{datetime.now()} [easyocr_worker] 未捕获异常:\n{msg}")
_write_state("crashed", error=msg[:500])
sys.excepthook = _global_excepthook
@@ -172,6 +212,7 @@ except Exception as e:
def _init_reader():
"""初始化 EasyOCR reader(GPU 优先,父进程可强制 CPU)。"""
global _WORKER_MODE
# 先检查环境变量是否强制CPU
_force_cpu = os.environ.get("EASYOCR_FORCE_CPU") == "1"
@@ -215,6 +256,7 @@ def _init_reader():
try:
reader = easyocr.Reader(["ch_sim", "en"], gpu=use_gpu)
mode = "GPU" if use_gpu else "CPU"
_WORKER_MODE = mode
sys.stderr.write(f"[easyocr_worker] Reader 就绪({mode}模式)\n")
sys.stderr.flush()
return reader
@@ -223,6 +265,7 @@ def _init_reader():
sys.stderr.write(f"[easyocr_worker] GPU 初始化失败({e}),尝试 CPU\n")
sys.stderr.flush()
reader = easyocr.Reader(["ch_sim", "en"], gpu=False)
_WORKER_MODE = "CPU"
sys.stderr.write("[easyocr_worker] Reader 就绪(CPU 模式)\n")
sys.stderr.flush()
return reader
@@ -256,6 +299,7 @@ def _main():
sys.stdout.flush()
sys.stderr.write("[easyocr_worker] 就绪,等待 stdin 输入\n")
sys.stderr.flush()
_write_state("idle")
for line in sys.stdin:
line = line.strip()
@@ -267,6 +311,7 @@ def _main():
img_b64 = payload.get("img", "")
if not img_b64:
raise ValueError("缺少 img 字段")
_write_state("busy", last_img_len=len(img_b64))
sys.stderr.write(f"[easyocr_worker] 收到请求,处理中... (img长度={len(img_b64)})\n")
sys.stderr.flush()
text = _process_image(img_b64, reader)
@@ -275,6 +320,7 @@ def _main():
out = json.dumps({"ok": True, "text": text}, ensure_ascii=False)
sys.stdout.write(out + "\n")
sys.stdout.flush()
_write_state("idle")
except Exception as e:
estr = str(e)
# ⭐ CUDA OOM / kernel error:GPU context 已损坏,继续跑只会反复卡死占显存
@@ -306,7 +352,8 @@ def _cleanup_gpu():
def _signal_handler(signum, frame):
"""捕获 SIGTERM/SIGINT,先清理 GPU 再退出(proc.terminate() 触发此处理)。"""
"""捕获 SIGTERM/SIGINT,先记录被终止状态再清理 GPU 退出(proc.terminate() 触发此处理)。"""
_write_state("terminated", error=f"收到信号 {signum},父进程请求终止,正在清理退出")
_cleanup_gpu()
sys.exit(0)
+104 -7
View File
@@ -558,28 +558,95 @@ class MessageMonitor:
terminate() 发 SIGTERM 让 worker 有机会执行 atexit/signal 清理 CUDA 缓存;
3s 后仍存活则 kill() 强杀;最后 gc.collect() 释放主进程侧引用。
⭐ 杀前诊断:先收集 worker 状态文件 + 残留 stderr + 崩溃日志尾部,
汇总写入主日志(warning 级别),确保任何崩溃/超时杀进程都有迹可循,
杜绝"一声不吭就结束进程"导致后续排障无据。
"""
import gc
import threading
if not self._easyocr_proc or self._easyocr_proc.poll() is not None:
self._easyocr_proc = None
return
_tag = f"[ocr] 杀 EasyOCR 子进程({reason})" if reason else "[ocr] 杀 EasyOCR 子进程"
_proc = self._easyocr_proc
# ── 杀前诊断:收集 worker 死前状态,避免"一声不吭" ──
_diag = []
try:
_state = _read_easyocr_worker_state()
if _state:
_st = _state.get("status", "?")
_iso = _state.get("iso", "")
_mode = _state.get("mode", "?")
_img_len = _state.get("last_img_len", 0)
_err = _state.get("error", "")
_now = time.time()
_age = _now - float(_state.get("ts", _now)) if _state.get("ts") else None
_age_s = f",距现在{_age:.1f}s" if _age is not None else ""
_diag.append(
f"worker死前状态={_st}(模式{_mode}, 上次img长度{_img_len}, 更新于{_iso}{_age_s})"
+ (f", 错误={_err}" if _err else "")
)
except Exception:
pass
try:
# 非阻塞+限时读 worker 残留 stderr(崩溃前最后打印的内容),不阻塞杀进程
if _proc.stderr is not None:
from queue import Queue, Empty as _QE
_q = Queue()
def _drain_stderr():
try:
_b = _proc.stderr.read(65536)
_q.put(_b or "")
except Exception:
_q.put("")
_dt = threading.Thread(target=_drain_stderr, daemon=True)
_dt.start()
_dt.join(timeout=0.5)
_stderr_buf = ""
if not _dt.is_alive():
try:
_stderr_buf = _q.get_nowait()
except _QE:
pass
if isinstance(_stderr_buf, str) and _stderr_buf.strip():
_diag.append(f"worker残留stderr={_stderr_buf.strip()[-500:]}")
except Exception:
pass
try:
# 崩溃日志尾部(kill 诊断路径不过 60s 窗口过滤)
_crash = _read_easyocr_crash_tail(ignore_age=True)
if _crash:
_diag.append(f"崩溃日志={_crash}")
except Exception:
pass
try:
# 1) 先 terminate 让 worker 执行 GPU 清理(atexit/signal handler)
self._easyocr_proc.terminate()
_proc.terminate()
try:
self._easyocr_proc.wait(timeout=3)
_proc.wait(timeout=3)
logger.debug(f"{_tag}: terminate 后正常退出")
except Exception:
# 2) 3s 内未退出 → 强杀
try:
self._easyocr_proc.kill()
self._easyocr_proc.wait(timeout=3)
_proc.kill()
_proc.wait(timeout=3)
logger.warning(f"{_tag}: terminate 无效,已 kill")
except Exception:
pass
except Exception as e:
logger.debug(f"{_tag} 异常: {e}")
# 杀后:把诊断信息汇总写入主日志(warning 确保可见)
if _diag:
logger.warning(f"{_tag} 诊断: " + " | ".join(_diag))
else:
logger.debug(f"{_tag}: 无可用诊断信息(worker 未留下状态文件/崩溃日志)")
self._easyocr_proc = None
# 3) 主进程 GC:释放 PIL Image / numpy array 等残留引用
try:
@@ -5907,17 +5974,19 @@ def _truncate_easyocr_crash_log():
pass # 截断失败不阻断
def _read_easyocr_crash_tail(max_bytes: int = 2000) -> str:
def _read_easyocr_crash_tail(max_bytes: int = 2000, ignore_age: bool = False) -> str:
"""读取 easyocr_worker 崩溃日志尾部,供父进程在检测到 worker 崩溃时把原因写进主日志。
只在文件存在且较新时读取,避免每次都读陈旧历史。
ignore_age=True 用于杀进程诊断路径:worker 死前刚写的崩溃日志可能 mtime 较旧,
此时不按 60s 窗口过滤,确保把死因带出来。
"""
try:
path = _get_easyocr_crash_log()
if not os.path.exists(path):
return ""
# 只看最近 60s 内的崩溃(文件可能被很久以前的残留占据)
if time.time() - os.path.getmtime(path) > 60:
# 只看最近 60s 内的崩溃(文件可能被很久以前的残留占据);诊断路径跳过该限制
if not ignore_age and time.time() - os.path.getmtime(path) > 60:
return ""
with open(path, "r", encoding="utf-8", errors="ignore") as f:
data = f.read()
@@ -5929,6 +5998,34 @@ def _read_easyocr_crash_tail(max_bytes: int = 2000) -> str:
return ""
def _get_easyocr_state_path() -> str:
"""返回 worker 状态文件路径,必须与 easyocr_worker.py 中 STATE_PATH 保持一致。"""
try:
base = os.path.dirname(os.path.abspath(__file__))
except Exception:
base = "."
return os.path.join(base, "easyocr_worker_state.json")
def _read_easyocr_worker_state() -> dict:
"""读取 worker 状态文件,返回死前状态(pid/mode/status/last_img_len/ts/iso/error)。
父进程杀子进程前调用,用于回答"worker 死时卡在哪个请求、卡了多久、什么模式"。
"""
try:
path = _get_easyocr_state_path()
if not os.path.exists(path):
return {}
with open(path, "r", encoding="utf-8", errors="ignore") as f:
data = f.read().strip()
if not data:
return {}
import json as _json
return _json.loads(data)
except Exception:
return {}
def _walk_list_controls(ctrl, depth):
if depth > 6:
return []
+1
View File
@@ -215,6 +215,7 @@
| 齒 | - | 齿字旁 | 龄→齒令 | 龄 |
| 龍 | - | 龙字旁 | 聋→龍耳 | 聋 |
| 龜 | - | 龟 | 亀→龜 | 亀 |
| 丿 | - | 撇 | 儿→丿 | 儿
## 使用说明