Files
chongming-core/test/test_logging_fork.py
T

101 lines
4.0 KiB
Python
Raw Normal View History

"""
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("✅ 全部通过")