"""SafeRotatingFileHandler:Windows 轮转不被外部句柄卡死(WinError 32)。 stdlib RotatingFileHandler 靠 rename 活动文件轮转;Windows 上只要有第二个句柄(admin 第二进程、残留 --reload worker、IDE 索引、杀软)开着它, rename 就 WinError 32、轮转永久 卡死。Safe 版在 Windows 改走 copytruncate(拷贝→就地清空, 从不 rename 活动文件)。 这些用例用 monkeypatch 把 os.name 强制成 "nt", 使 copytruncate 分支在任何 OS 的 CI 上都被 覆盖;在真实 Windows 上则天然命中。 """ from __future__ import annotations import logging from pathlib import Path from app.core.logging import SafeRotatingFileHandler def _emit(handler: logging.Handler, msg: str) -> None: handler.emit(logging.LogRecord("t", logging.INFO, __file__, 0, msg, (), None)) def test_rollover_survives_second_open_handle(tmp_path: Path, monkeypatch) -> None: """第二个句柄开着活动文件时轮转:不抛异常, 且确实转了(生成 .1、活动文件就地清空)。""" monkeypatch.setattr("app.core.logging.os.name", "nt") log_file = tmp_path / "app-server.log" # maxBytes 放大, 避免 emit 期间自动轮转干扰;本用例手动触发 doRollover。 handler = SafeRotatingFileHandler( str(log_file), maxBytes=10**9, backupCount=3, encoding="utf-8", ) handler.setFormatter(logging.Formatter("%(message)s")) try: for i in range(20): _emit(handler, f"line-{i:03d}") handler.flush() before = log_file.stat().st_size assert before > 0 # 正是 Windows 上 rename 失败的条件:另一个句柄开着活动文件。 with open(log_file, "a", encoding="utf-8"): handler.doRollover() # 不应抛 PermissionError / WinError 32 backup = tmp_path / "app-server.log.1" assert backup.exists() assert backup.stat().st_size == before # 轮转前内容完整进了备份 assert log_file.stat().st_size == 0 # 活动文件就地清空(不是 rename) # 句柄没被 rename 破坏, 仍能继续写。 _emit(handler, "after-rollover") handler.flush() assert log_file.stat().st_size > 0 finally: handler.close() def test_backups_shift_and_capped(tmp_path: Path, monkeypatch) -> None: """多次轮转:.1/.2 逐级腾挪, 超过 backupCount 的丢弃(不出现 .3)。""" monkeypatch.setattr("app.core.logging.os.name", "nt") log_file = tmp_path / "app-server.log" handler = SafeRotatingFileHandler( str(log_file), maxBytes=10**9, backupCount=2, encoding="utf-8", ) handler.setFormatter(logging.Formatter("%(message)s")) try: for _ in range(4): _emit(handler, "x" * 50) handler.doRollover() assert (tmp_path / "app-server.log.1").exists() assert (tmp_path / "app-server.log.2").exists() assert not (tmp_path / "app-server.log.3").exists() finally: handler.close()