101 lines
4.0 KiB
Python
101 lines
4.0 KiB
Python
"""
|
||||
|
|
fork 安全:文件日志流在子进程重开(reentrant call 修复)测试
|
|||
|
|
|
|||
|
|
背景:gateway 的日志是"队列 handler + 监听线程"结构,文件 handler 的流在父进程
|
|||
|
|
打开。granian/gunicorn 的 worker 由 master **fork** 而来(实测 worker 与 master 的
|
|||
|
|
fd 表完全一致)——子进程会继承父进程的 BufferedWriter,其内部锁/owner 状态在
|
|||
|
|
fork 瞬间可能正被父进程监听线程占用(跨 fork 复制不可靠)。症状:冻结版(Nuitka)
|
|||
|
|
里每个 worker 的**每条文件日志**都报
|
|||
|
|
RuntimeError: reentrant call inside <_io.BufferedWriter ...>
|
|||
|
|
(控制台 handler 正常,文件日志整体失效)。
|
|||
|
|
|
|||
|
|
修复:_after_fork_child 里把文件 handler 的流在子进程重开(新文件描述符)。
|
|||
|
|
本测试验证:
|
|||
|
|
1. fork 后子进程的 handler.stream 不再是继承的那个对象(结构保证)
|
|||
|
|
2. 子进程能真正把记录写进日志文件(行为保证——旧实现下这里写不进去)
|
|||
|
|
3. 控制台等非文件 handler 不被动(无 _open,跳过)
|
|||
|
|
|
|||
|
|
运行: uv run python test_logging_fork.py
|
|||
|
|
"""
|
|||
|
|
import importlib.util
|
|||
|
|
import logging
|
|||
|
|
import os
|
|||
|
|
import sys
|
|||
|
|
import tempfile
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
tmp = tempfile.TemporaryDirectory()
|
|||
|
|
log_dir = Path(tmp.name)
|
|||
|
|
|
|||
|
|
# 与 test_logging_async.py 相同的加载方式:独立加载 logger 模块并把日志目录重定向
|
|||
|
|
_logger_path = (
|
|||
|
|
Path(__file__).resolve().parent.parent / "gateway" / "src" / "chongming" / "app" / "core" / "logger.py"
|
|||
|
|
)
|
|||
|
|
_spec = importlib.util.spec_from_file_location("chongming.app.core.logger", _logger_path)
|
|||
|
|
logger_mod = importlib.util.module_from_spec(_spec)
|
|||
|
|
sys.modules["chongming.app.core.logger"] = logger_mod
|
|||
|
|
_spec.loader.exec_module(logger_mod) # type: ignore[union-attr]
|
|||
|
|
logger_mod.log_config["file"] = str(log_dir)
|
|||
|
|
|
|||
|
|
failures = []
|
|||
|
|
|
|||
|
|
|
|||
|
|
def check(name, cond, detail=""):
|
|||
|
|
status = "PASS" if cond else "FAIL"
|
|||
|
|
print(f"[{status}] {name}" + (f" — {detail}" if detail else ""))
|
|||
|
|
if not cond:
|
|||
|
|
failures.append(name)
|
|||
|
|
|
|||
|
|
|
|||
|
|
check("os.register_at_fork 已注册 after_in_child", hasattr(os, "register_at_fork"))
|
|||
|
|
|
|||
|
|
logger = logging.getLogger("forktest")
|
|||
|
|
logger_mod.setup_logging(logger, "forktest")
|
|||
|
|
|
|||
|
|
file_handler = next(
|
|||
|
|
h for h in logger_mod._listener_handlers if isinstance(h, logger_mod.DailyRotatingFileHandler)
|
|||
|
|
)
|
|||
|
|
# 同步直写一条(不经队列),保持监听线程与流都活着,便于接下来 fork
|
|||
|
|
parent_record = logging.LogRecord("forktest", logging.INFO, __file__, 0, "parent-line", (), None)
|
|||
|
|
file_handler.handle(parent_record)
|
|||
|
|
file_handler.flush()
|
|||
|
|
|
|||
|
|
check("父进程日志已落盘", "parent-line" in next(iter(log_dir.glob("forktest-*.log"))).read_text(encoding="utf-8"))
|
|||
|
|
|
|||
|
|
inherited_stream = file_handler.stream
|
|||
|
|
check("父进程流已打开", inherited_stream is not None)
|
|||
|
|
|
|||
|
|
# ---- fork:子进程应换掉继承的流,并能独立写日志 ----
|
|||
|
|
pid = os.fork()
|
|||
|
|
if pid == 0: # 子进程
|
|||
|
|
try:
|
|||
|
|
reopened = file_handler.stream is not inherited_stream
|
|||
|
|
record = logging.LogRecord(
|
|||
|
|
"forktest", logging.INFO, __file__, 0, "child-line", (), None
|
|||
|
|
)
|
|||
|
|
# 直接同步走 handler(不经队列),写入结果立即可断言
|
|||
|
|
file_handler.handle(record)
|
|||
|
|
os._exit(0 if reopened else 3)
|
|||
|
|
except BaseException: # noqa: BLE001 - 子进程异常用退出码传出
|
|||
|
|
os._exit(4)
|
|||
|
|
|
|||
|
|
_, status = os.waitpid(pid, 0)
|
|||
|
|
exit_code = os.waitstatus_to_exitcode(status)
|
|||
|
|
check("fork 后子进程的流已换新(不再是继承对象)", exit_code == 0, f"exit={exit_code}")
|
|||
|
|
check(
|
|||
|
|
"子进程可直接写入日志文件(旧实现下 reentrant 失败)",
|
|||
|
|
"child-line" in next(iter(log_dir.glob("forktest-*.log"))).read_text(encoding="utf-8"),
|
|||
|
|
)
|
|||
|
|
check(
|
|||
|
|
"控制台 handler 不受影响(仍指向 stderr)",
|
|||
|
|
any(isinstance(h, logging.StreamHandler) and not isinstance(h, logger_mod.DailyRotatingFileHandler) for h in logger_mod._listener_handlers),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
tmp.cleanup()
|
|||
|
|
|
|||
|
|
print("\n" + ("=" * 40))
|
|||
|
|
if failures:
|
|||
|
|
print(f"❌ {len(failures)} 项未通过: {failures}")
|
|||
|
|
sys.exit(1)
|
|||
|
|
print("✅ 全部通过")
|