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:
+48
-1
@@ -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
@@ -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 []
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
| 齒 | - | 齿字旁 | 龄→齒令 | 龄 |
|
||||
| 龍 | - | 龙字旁 | 聋→龍耳 | 聋 |
|
||||
| 龜 | - | 龟 | 亀→龜 | 亀 |
|
||||
| 丿 | - | 撇 | 儿→丿 | 儿
|
||||
|
||||
## 使用说明
|
||||
|
||||
|
||||
Reference in New Issue
Block a user