Files
MoviePilot/tests/test_log_shutdown.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

464 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
验证日志关闭、刷新及并发创建文件处理器时的资源回收。
"""
import subprocess
import sys
import threading
import time
from unittest.mock import MagicMock
import pytest
from app.runtime.log import (
LogEntry,
LoggerManager,
NonBlockingFileHandler,
log_settings,
shutdown_log_writer,
start_log_writer,
)
def test_runtime_config_cold_import_does_not_start_log_writer() -> None:
"""
冷导入配置模块不得创建日志后台线程。
"""
result = subprocess.run(
[
sys.executable,
"-c",
(
"import threading; before={id(t) for t in threading.enumerate()}; "
"import app.runtime.config; "
"created=[t.name for t in threading.enumerate() if id(t) not in before]; "
"assert 'LogBatchWriter' not in created, created"
),
],
check=False,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
def test_log_writer_restarts_with_new_owner_after_shutdown(tmp_path) -> None:
"""
完整关闭后下一轮 lifespan 必须创建新的日志 owner。
"""
previous_writer = LoggerManager._writer
previous_log_path = LoggerManager._log_path
previous_instance = NonBlockingFileHandler._instance
LoggerManager._writer = None
LoggerManager._log_path = None
NonBlockingFileHandler._instance = None
try:
first = start_log_writer(tmp_path)
assert shutdown_log_writer(first) is True
second = start_log_writer(tmp_path)
assert second is not first
assert shutdown_log_writer(second) is True
finally:
shutdown_log_writer()
LoggerManager._writer = previous_writer
LoggerManager._log_path = previous_log_path
NonBlockingFileHandler._instance = previous_instance
def test_start_log_writer_rejects_retained_stopping_owner_until_retry(
monkeypatch,
tmp_path,
) -> None:
"""
关闭失败后不得复用停止态 writer;重试收敛后才能创建新 owner。
"""
previous_writer = LoggerManager._writer
previous_log_path = LoggerManager._log_path
previous_instance = NonBlockingFileHandler._instance
LoggerManager._writer = None
LoggerManager._log_path = None
NonBlockingFileHandler._instance = None
monkeypatch.setattr(log_settings, "WRITE_TIMEOUT", 0.01)
first = start_log_writer(tmp_path)
entered = threading.Event()
release = threading.Event()
real_shutdown = first.shutdown
def block_batch(_batch: list[LogEntry]) -> None:
"""
占住真实 writer 线程,制造一次可恢复的关闭超时。
"""
entered.set()
release.wait()
monkeypatch.setattr(first, "_write_batch", block_batch)
monkeypatch.setattr(first, "shutdown", lambda: real_shutdown(timeout=0.01))
try:
assert first._write_non_blocking(
LogEntry("info", "blocked", tmp_path / "retry.log")
) is True
assert entered.wait(timeout=1)
assert shutdown_log_writer(first) is False
assert LoggerManager.current_writer() is first
assert NonBlockingFileHandler.get_existing_instance() is first
with pytest.raises(RuntimeError, match="既有日志 writer 尚未释放"):
start_log_writer(tmp_path)
assert LoggerManager.current_writer() is first
assert NonBlockingFileHandler.get_existing_instance() is first
release.set()
assert shutdown_log_writer(first) is True
second = start_log_writer(tmp_path)
assert second is not first
assert shutdown_log_writer(second) is True
finally:
release.set()
real_shutdown(timeout=1)
shutdown_log_writer()
LoggerManager._writer = previous_writer
LoggerManager._log_path = previous_log_path
NonBlockingFileHandler._instance = previous_instance
def test_non_blocking_file_handler_shutdown_wakes_writer_and_closes_handlers(tmp_path):
"""
日志关闭应立即唤醒空闲写线程,并关闭所有已打开的文件处理器
"""
original_instance = NonBlockingFileHandler._instance
NonBlockingFileHandler._instance = None
handler = NonBlockingFileHandler()
handler._rotating_handlers = {}
log_handler = handler._get_rotating_handler(tmp_path / "shutdown.log")
try:
started_at = time.monotonic()
handler.shutdown()
elapsed = time.monotonic() - started_at
assert elapsed < 1
assert not handler._write_thread.is_alive()
assert log_handler.stream is None
assert handler._write_non_blocking(
LogEntry("info", "late-message", tmp_path / "shutdown.log")
) is False
assert handler._write_queue.empty()
finally:
if handler._write_thread.is_alive():
handler._running = False
handler._write_thread.join(timeout=5)
if log_handler.stream is not None:
log_handler.close()
NonBlockingFileHandler._instance = original_instance
def test_non_blocking_file_handler_shutdown_drains_queued_batches(monkeypatch, tmp_path):
"""
停止标记之前已进入队列的日志应跨批次全部写完
"""
original_instance = NonBlockingFileHandler._instance
NonBlockingFileHandler._instance = None
monkeypatch.setattr(log_settings, "BATCH_WRITE_SIZE", 2)
handler = NonBlockingFileHandler()
handler._rotating_handlers = {}
written = []
monkeypatch.setattr(
handler,
"_write_batch",
lambda batch: written.extend(entry.message for entry in batch),
)
try:
for index in range(5):
handler._write_non_blocking(
LogEntry("info", f"message-{index}", tmp_path / "drain.log")
)
handler.shutdown()
assert written == [f"message-{index}" for index in range(5)]
assert not handler._write_thread.is_alive()
finally:
if handler._write_thread.is_alive():
handler._running = False
handler._write_queue.put(handler._stop_sentinel)
handler._write_thread.join(timeout=5)
NonBlockingFileHandler._instance = original_instance
def test_non_blocking_file_handler_creates_one_handler_for_concurrent_first_write(monkeypatch, tmp_path):
"""
同一路径首次并发写入时只创建并关闭一个文件处理器
"""
original_instance = NonBlockingFileHandler._instance
NonBlockingFileHandler._instance = None
handler = NonBlockingFileHandler()
handler._rotating_handlers = {}
first_created = threading.Event()
second_started = threading.Event()
release_first = threading.Event()
created_handlers = []
results = []
class ProbeHandler:
"""
记录处理器创建和关闭状态,通过事件扩大并发创建窗口。
"""
def __init__(self, **kwargs):
self.closed = False
created_handlers.append(self)
if len(created_handlers) == 1:
first_created.set()
release_first.wait(timeout=2)
@staticmethod
def setFormatter(formatter):
"""
接受格式器配置,测试替身不进行真实日志格式化。
"""
pass
@staticmethod
def flush():
"""
模拟无缓冲的处理器刷新,不执行文件写入。
"""
pass
def close(self):
"""
标记处理器已关闭,供资源泄漏断言使用。
"""
self.closed = True
monkeypatch.setattr("app.runtime.log.RotatingFileHandler", ProbeHandler)
file_path = tmp_path / "concurrent.log"
def get_handler(started=None):
if started:
started.set()
results.append(handler._get_rotating_handler(file_path))
first = threading.Thread(target=get_handler)
second = threading.Thread(target=get_handler, args=(second_started,))
try:
first.start()
assert first_created.wait(timeout=1)
second.start()
assert second_started.wait(timeout=1)
time.sleep(0.05)
release_first.set()
first.join(timeout=2)
second.join(timeout=2)
assert len(created_handlers) == 1
assert results[0] is results[1]
handler.shutdown()
assert created_handlers[0].closed is True
finally:
release_first.set()
first.join(timeout=2)
second.join(timeout=2)
handler.shutdown()
NonBlockingFileHandler._instance = original_instance
def test_non_blocking_file_handler_uses_handler_lock(monkeypatch, tmp_path):
"""
日志写入通过 Handler 入口串行化 emit 与 rollover
"""
original_instance = NonBlockingFileHandler._instance
NonBlockingFileHandler._instance = None
handler = NonBlockingFileHandler()
handler._rotating_handlers = {}
log_handler = MagicMock()
monkeypatch.setattr(handler, "_get_rotating_handler", MagicMock(return_value=log_handler))
try:
handler._write_sync(LogEntry("info", "message", tmp_path / "locked.log"))
log_handler.handle.assert_called_once()
log_handler.emit.assert_not_called()
finally:
handler.shutdown()
NonBlockingFileHandler._instance = original_instance
def test_non_blocking_file_handler_shutdown_is_bounded_and_retryable(
monkeypatch,
tmp_path,
):
"""
批量写入阻塞时关闭必须有限返回,并保留同一 writer 供重试。
"""
original_instance = NonBlockingFileHandler._instance
NonBlockingFileHandler._instance = None
monkeypatch.setattr(log_settings, "WRITE_TIMEOUT", 0.01)
handler = NonBlockingFileHandler()
entered = threading.Event()
release = threading.Event()
def block_batch(_batch):
"""
模拟文件系统写入永久占用批量 writer。
"""
entered.set()
release.wait()
monkeypatch.setattr(handler, "_write_batch", block_batch)
try:
assert handler._write_non_blocking(
LogEntry("info", "blocked", tmp_path / "blocked.log")
) is True
assert entered.wait(timeout=1)
started_at = time.monotonic()
assert handler.shutdown(timeout=0.01) is False
assert time.monotonic() - started_at < 1
assert handler._write_thread.is_alive()
assert handler._write_non_blocking(
LogEntry("info", "late", tmp_path / "blocked.log")
) is False
release.set()
assert handler.shutdown(timeout=1) is True
assert not handler._write_thread.is_alive()
assert handler.shutdown(timeout=1) is True
finally:
release.set()
handler.shutdown(timeout=1)
NonBlockingFileHandler._instance = original_instance
def test_non_blocking_file_handler_does_not_bypass_full_queue(
monkeypatch,
tmp_path,
):
"""
队列达到显式容量后不得再通过无界线程池形成第二条写入路径。
"""
original_instance = NonBlockingFileHandler._instance
NonBlockingFileHandler._instance = None
monkeypatch.setattr(log_settings, "ASYNC_FILE_QUEUE_SIZE", 1)
monkeypatch.setattr(log_settings, "BATCH_WRITE_SIZE", 1)
handler = NonBlockingFileHandler()
entered = threading.Event()
release = threading.Event()
written: list[str] = []
def block_batch(batch):
"""
占住唯一 writer,使后续日志稳定留在有界队列中。
"""
written.extend(entry.message for entry in batch)
entered.set()
release.wait()
monkeypatch.setattr(handler, "_write_batch", block_batch)
try:
assert handler._write_non_blocking(
LogEntry("info", "first", tmp_path / "bounded.log")
) is True
assert entered.wait(timeout=1)
assert handler._write_non_blocking(
LogEntry("info", "queued", tmp_path / "bounded.log")
) is True
assert handler._write_non_blocking(
LogEntry("info", "rejected", tmp_path / "bounded.log")
) is False
release.set()
started_at = time.monotonic()
assert handler.shutdown(timeout=1) is True
assert time.monotonic() - started_at < 1
assert written == ["first", "queued"]
finally:
release.set()
handler.shutdown(timeout=1)
NonBlockingFileHandler._instance = original_instance
def test_non_blocking_file_handler_bounds_handler_close(monkeypatch, tmp_path):
"""
文件处理器 close 阻塞时也必须保留关闭线程并支持最终重试。
"""
original_instance = NonBlockingFileHandler._instance
NonBlockingFileHandler._instance = None
handler = NonBlockingFileHandler()
close_entered = threading.Event()
close_release = threading.Event()
log_handler = MagicMock()
def block_close():
"""
模拟文件系统在 flush 后阻塞关闭句柄。
"""
close_entered.set()
close_release.wait()
log_handler.close.side_effect = block_close
log_path = tmp_path / "close.log"
handler._rotating_handlers = {log_path: log_handler}
try:
assert handler.shutdown(timeout=0.05) is False
assert close_entered.wait(timeout=1)
assert handler._close_thread is not None
assert handler._close_thread.is_alive()
assert handler._rotating_handlers[log_path] is log_handler
close_release.set()
assert handler.shutdown(timeout=1) is True
assert handler._rotating_handlers == {}
finally:
close_release.set()
handler.shutdown(timeout=1)
NonBlockingFileHandler._instance = original_instance
def test_logger_manager_retains_nonconverged_writer_for_retry(tmp_path):
"""
平台日志门面不得在底层 writer 未收敛时丢失其 owner。
"""
previous_writer = LoggerManager._writer
previous_log_path = LoggerManager._log_path
writer = MagicMock()
writer.shutdown.side_effect = [False, True]
LoggerManager._writer = writer
LoggerManager._log_path = tmp_path
try:
assert LoggerManager.shutdown() is False
assert LoggerManager._writer is writer
assert LoggerManager._log_path == tmp_path
assert LoggerManager.shutdown() is True
assert LoggerManager._writer is None
assert LoggerManager._log_path is None
assert writer.shutdown.call_count == 2
finally:
LoggerManager._writer = previous_writer
LoggerManager._log_path = previous_log_path
def test_logger_manager_refuses_to_replace_nonconverged_writer(tmp_path):
"""
重新装配不得用新 writer 覆盖仍持有资源的旧 owner。
"""
original_writer = LoggerManager._writer
original_log_path = LoggerManager._log_path
previous_writer = MagicMock()
previous_writer.shutdown.return_value = False
replacement_writer = MagicMock()
old_path = tmp_path / "old"
LoggerManager._writer = previous_writer
LoggerManager._log_path = old_path
try:
with pytest.raises(RuntimeError, match="既有日志写入器未收敛"):
LoggerManager.configure_writer(replacement_writer, tmp_path / "new")
assert LoggerManager._writer is previous_writer
assert LoggerManager._log_path == old_path
replacement_writer.write_log.assert_not_called()
finally:
LoggerManager._writer = original_writer
LoggerManager._log_path = original_log_path