refactor(runtime): tighten resource cleanup and test isolation (#6116)

This commit is contained in:
InfinityPacer
2026-07-14 16:03:29 +08:00
committed by GitHub
parent e015c67689
commit f814c271cc
21 changed files with 563 additions and 99 deletions
+44 -11
View File
@@ -18,8 +18,10 @@ from app.log import logger
from app.utils.mixins import ConfigReloadMixin from app.utils.mixins import ConfigReloadMixin
from app.utils.singleton import Singleton from app.utils.singleton import Singleton
# 定义一个全局线程池执行器 # DoH 关闭时需要释放线程池;保持惰性创建可避免未启用 DoH 时占用进程级资源
_executor = concurrent.futures.ThreadPoolExecutor() _executor: Optional[concurrent.futures.ThreadPoolExecutor] = None
_executor_lock = Lock()
_doh_enabled = False
# 定义默认的DoH配置 # 定义默认的DoH配置
_doh_timeout = 5 _doh_timeout = 5
@@ -29,11 +31,21 @@ _doh_lock = Lock()
_orig_getaddrinfo = socket.getaddrinfo _orig_getaddrinfo = socket.getaddrinfo
def _get_executor_locked() -> concurrent.futures.ThreadPoolExecutor:
"""在持有执行器锁时按需获取 DoH 查询线程池"""
global _executor
if _executor is None:
_executor = concurrent.futures.ThreadPoolExecutor()
return _executor
def enable_doh(enable: bool) -> None: def enable_doh(enable: bool) -> None:
""" """
对 socket.getaddrinfo 进行补丁 对 socket.getaddrinfo 进行补丁
""" """
global _doh_enabled
def _patched_getaddrinfo(host: str, *args, **kwargs): def _patched_getaddrinfo(host: str, *args, **kwargs):
""" """
socket.getaddrinfo的补丁版本。 socket.getaddrinfo的补丁版本。
@@ -47,9 +59,15 @@ def enable_doh(enable: bool) -> None:
logger.info(f"已解析 [{host}] 为 [{ip}] (缓存)") logger.info(f"已解析 [{host}] 为 [{ip}] (缓存)")
return _orig_getaddrinfo(ip, *args, **kwargs) return _orig_getaddrinfo(ip, *args, **kwargs)
# 使用DoH解析主机 # 使用DoH解析主机
futures = [] with _executor_lock:
for resolver in settings.DOH_RESOLVERS.split(","): if not _doh_enabled:
futures.append(_executor.submit(_doh_query, resolver, host)) return _orig_getaddrinfo(host, *args, **kwargs)
executor = _get_executor_locked()
# 一次解析的任务必须在同一临界区提交完,避免关闭过程中部分任务落入新线程池
futures = [
executor.submit(_doh_query, resolver, host)
for resolver in settings.DOH_RESOLVERS.split(",")
]
for future in concurrent.futures.as_completed(futures): for future in concurrent.futures.as_completed(futures):
ip = future.result() ip = future.result()
if ip is not None: if ip is not None:
@@ -60,11 +78,9 @@ def enable_doh(enable: bool) -> None:
break break
return _orig_getaddrinfo(host, *args, **kwargs) return _orig_getaddrinfo(host, *args, **kwargs)
if enable: with _executor_lock:
# 替换 socket.getaddrinfo 方法 _doh_enabled = enable
socket.getaddrinfo = _patched_getaddrinfo socket.getaddrinfo = _patched_getaddrinfo if enable else _orig_getaddrinfo
else:
socket.getaddrinfo = _orig_getaddrinfo
class DohHelper(ConfigReloadMixin, metaclass=Singleton): class DohHelper(ConfigReloadMixin, metaclass=Singleton):
@@ -77,14 +93,31 @@ class DohHelper(ConfigReloadMixin, metaclass=Singleton):
enable_doh(settings.DOH_ENABLE) enable_doh(settings.DOH_ENABLE)
def on_config_changed(self) -> None: def on_config_changed(self) -> None:
if not settings.DOH_ENABLE:
self.shutdown()
return
with _doh_lock: with _doh_lock:
# DOH配置有变动的情况下,清空缓存 # DOH配置有变动的情况下,清空缓存
_doh_cache.clear() _doh_cache.clear()
enable_doh(settings.DOH_ENABLE) enable_doh(True)
def get_reload_name(self) -> str: def get_reload_name(self) -> str:
return 'DoH' return 'DoH'
def shutdown(self) -> None:
"""恢复系统 DNS 并释放 DoH 查询线程池"""
global _executor, _doh_enabled
with _executor_lock:
_doh_enabled = False
socket.getaddrinfo = _orig_getaddrinfo
executor = _executor
_executor = None
with _doh_lock:
_doh_cache.clear()
if executor:
executor.shutdown(wait=True)
def _doh_query(resolver: str, host: str) -> Optional[str]: def _doh_query(resolver: str, host: str) -> Optional[str]:
""" """
使用给定的DoH解析器查询给定主机的IP地址。 使用给定的DoH解析器查询给定主机的IP地址。
+9 -5
View File
@@ -605,6 +605,7 @@ class MessageQueueManager(metaclass=SingletonClass):
self.check_interval = check_interval self.check_interval = check_interval
self._running = True self._running = True
self._stop_event = threading.Event()
self.thread = threading.Thread(target=self._monitor_loop, daemon=True) self.thread = threading.Thread(target=self._monitor_loop, daemon=True)
self.thread.start() self.thread.start()
@@ -752,13 +753,15 @@ class MessageQueueManager(metaclass=SingletonClass):
logger.info(f"队列剩余消息:{self.queue.qsize()}") logger.info(f"队列剩余消息:{self.queue.qsize()}")
except queue.Empty: except queue.Empty:
break break
time.sleep(self.check_interval) if self._stop_event.wait(self.check_interval):
break
def stop(self) -> None: def stop(self) -> None:
""" """
停止队列管理器 停止队列管理器
""" """
self._running = False self._running = False
self._stop_event.set()
logger.info("正在停止消息队列...") logger.info("正在停止消息队列...")
self.thread.join() self.thread.join()
logger.info("消息队列已停止") logger.info("消息队列已停止")
@@ -841,7 +844,8 @@ def stop_message():
""" """
停止消息服务 停止消息服务
""" """
# 停止消息队列 # 只关闭已启动的服务,避免清理路径反向创建后台线程和缓存
MessageQueueManager().stop() if queue_manager := MessageQueueManager.get_existing_instance():
# 关闭消息演染器 queue_manager.stop()
TemplateHelper().close() if template_helper := TemplateHelper.get_existing_instance():
template_helper.close()
+58 -35
View File
@@ -124,7 +124,7 @@ class NonBlockingFileHandler:
""" """
_instance = None _instance = None
_lock = threading.Lock() _lock = threading.Lock()
_rotating_handlers = {} _stop_sentinel = object()
def __new__(cls): def __new__(cls):
if cls._instance is None: if cls._instance is None:
@@ -138,6 +138,9 @@ class NonBlockingFileHandler:
return return
self._initialized = True self._initialized = True
self._state_lock = threading.RLock()
self._handlers_lock = threading.Lock()
self._rotating_handlers = {}
self._write_queue = queue.Queue(maxsize=log_settings.ASYNC_FILE_QUEUE_SIZE) self._write_queue = queue.Queue(maxsize=log_settings.ASYNC_FILE_QUEUE_SIZE)
self._executor = ThreadPoolExecutor(max_workers=log_settings.ASYNC_FILE_WORKERS, self._executor = ThreadPoolExecutor(max_workers=log_settings.ASYNC_FILE_WORKERS,
thread_name_prefix="LogWriter") thread_name_prefix="LogWriter")
@@ -151,27 +154,28 @@ class NonBlockingFileHandler:
""" """
获取或创建RotatingFileHandler实例 获取或创建RotatingFileHandler实例
""" """
if file_path not in self._rotating_handlers: with self._handlers_lock:
# 确保目录存在 if file_path not in self._rotating_handlers:
file_path.parent.mkdir(parents=True, exist_ok=True) # 确保目录存在
file_path.parent.mkdir(parents=True, exist_ok=True)
# 创建RotatingFileHandler # 创建RotatingFileHandler
handler = RotatingFileHandler( handler = RotatingFileHandler(
filename=str(file_path), filename=str(file_path),
maxBytes=log_settings.LOG_MAX_FILE_SIZE_BYTES, maxBytes=log_settings.LOG_MAX_FILE_SIZE_BYTES,
backupCount=log_settings.LOG_BACKUP_COUNT, backupCount=log_settings.LOG_BACKUP_COUNT,
encoding='utf-8' encoding='utf-8'
) )
# 设置格式化器 # 设置格式化器
formatter = logging.Formatter(log_settings.LOG_FILE_FORMAT) formatter = logging.Formatter(log_settings.LOG_FILE_FORMAT)
handler.setFormatter(formatter) handler.setFormatter(formatter)
self._rotating_handlers[file_path] = handler self._rotating_handlers[file_path] = handler
return self._rotating_handlers[file_path] return self._rotating_handlers[file_path]
def write_log(self, level: str, message: str, file_path: Path): def write_log(self, level: str, message: str, file_path: Path) -> None:
""" """
写入日志 - 自动检测协程环境并使用合适的方式 写入日志 - 自动检测协程环境并使用合适的方式
""" """
@@ -181,8 +185,11 @@ class NonBlockingFileHandler:
if self._is_in_event_loop(): if self._is_in_event_loop():
# 在协程环境中,使用非阻塞方式 # 在协程环境中,使用非阻塞方式
self._write_non_blocking(entry) self._write_non_blocking(entry)
else: return
# 不在协程环境中,直接同步写入 with self._state_lock:
if not self._running:
return
# 不在协程环境中,持锁同步写入,避免关闭文件处理器时仍有写操作进行
self._write_sync(entry) self._write_sync(entry)
@staticmethod @staticmethod
@@ -196,15 +203,19 @@ class NonBlockingFileHandler:
except RuntimeError: except RuntimeError:
return False return False
def _write_non_blocking(self, entry: LogEntry): def _write_non_blocking(self, entry: LogEntry) -> bool:
""" """
非阻塞写入(用于协程环境) 非阻塞写入(用于协程环境)
""" """
try: with self._state_lock:
self._write_queue.put_nowait(entry) if not self._running:
except queue.Full: return False
# 队列满时,使用线程池处理 try:
self._executor.submit(self._write_sync, entry) self._write_queue.put_nowait(entry)
except queue.Full:
# 队列满时,使用线程池处理
self._executor.submit(self._write_sync, entry)
return True
@staticmethod @staticmethod
def _write_sync(entry: LogEntry): def _write_sync(entry: LogEntry):
@@ -215,8 +226,7 @@ class NonBlockingFileHandler:
# 获取RotatingFileHandler实例 # 获取RotatingFileHandler实例
handler = NonBlockingFileHandler()._get_rotating_handler(entry.file_path) handler = NonBlockingFileHandler()._get_rotating_handler(entry.file_path)
# 使用RotatingFileHandler的emit方法,只传递原始消息 handler.handle(logging.LogRecord(
handler.emit(logging.LogRecord(
name='', name='',
level=getattr(logging, entry.level.upper(), logging.INFO), level=getattr(logging, entry.level.upper(), logging.INFO),
pathname='', pathname='',
@@ -235,22 +245,28 @@ class NonBlockingFileHandler:
""" """
后台批量写入线程 后台批量写入线程
""" """
while self._running: while True:
try: try:
# 收集一批日志条目 # 收集一批日志条目
batch = [] batch = []
should_stop = False
end_time = time.time() + log_settings.WRITE_TIMEOUT end_time = time.time() + log_settings.WRITE_TIMEOUT
while len(batch) < log_settings.BATCH_WRITE_SIZE and time.time() < end_time: while len(batch) < log_settings.BATCH_WRITE_SIZE and time.time() < end_time:
try: try:
remaining_time = max(0, end_time - time.time()) remaining_time = max(0, end_time - time.time())
entry = self._write_queue.get(timeout=remaining_time) entry = self._write_queue.get(timeout=remaining_time)
if entry is self._stop_sentinel:
should_stop = True
break
batch.append(entry) batch.append(entry)
except queue.Empty: except queue.Empty:
break break
if batch: if batch:
self._write_batch(batch) self._write_batch(batch)
if should_stop:
break
except Exception as e: except Exception as e:
print(f"批量写入线程错误: {e}") print(f"批量写入线程错误: {e}")
@@ -275,8 +291,7 @@ class NonBlockingFileHandler:
# 批量写入 # 批量写入
for entry in entries: for entry in entries:
# 使用RotatingFileHandler的emit方法,只传递原始消息 handler.handle(logging.LogRecord(
handler.emit(logging.LogRecord(
name='', name='',
level=getattr(logging, entry.level.upper(), logging.INFO), level=getattr(logging, entry.level.upper(), logging.INFO),
pathname='', pathname='',
@@ -294,15 +309,23 @@ class NonBlockingFileHandler:
def shutdown(self): def shutdown(self):
""" """
关闭文件处理器 排空异步日志并关闭文件处理器
""" """
self._running = False with self._state_lock:
if hasattr(self, '_write_thread'): if not self._running:
self._write_thread.join(timeout=5) return
self._running = False
if hasattr(self, '_write_thread') and self._write_thread.is_alive():
# 状态锁保证停止标记之后不会再有生产者入队
self._write_queue.put(self._stop_sentinel)
if hasattr(self, '_write_thread') and self._write_thread.is_alive():
self._write_thread.join()
if self._executor: if self._executor:
self._executor.shutdown(wait=True) self._executor.shutdown(wait=True)
# 清理缓存 for handler in self._rotating_handlers.values():
handler.flush()
handler.close()
self._rotating_handlers.clear() self._rotating_handlers.clear()
-4
View File
@@ -279,8 +279,6 @@ class Telegram:
@staticmethod @staticmethod
def _telegramify_item_text(item: Text) -> str: def _telegramify_item_text(item: Text) -> str:
"""将 telegramify 文本片段转换为 Telegram MarkdownV2 字符串。""" """将 telegramify 文本片段转换为 Telegram MarkdownV2 字符串。"""
if hasattr(item, "content"):
return item.content
if entities_to_markdownv2: if entities_to_markdownv2:
return entities_to_markdownv2(item.text, item.entities) return entities_to_markdownv2(item.text, item.entities)
return standardize(item.text) return standardize(item.text)
@@ -290,8 +288,6 @@ class Telegram:
"""将 telegramify 文本或媒体片段转换为 Telegram MarkdownV2 caption。""" """将 telegramify 文本或媒体片段转换为 Telegram MarkdownV2 caption。"""
if isinstance(item, Text): if isinstance(item, Text):
return Telegram._telegramify_item_text(item) return Telegram._telegramify_item_text(item)
if hasattr(item, "caption"):
return item.caption
if entities_to_markdownv2: if entities_to_markdownv2:
return entities_to_markdownv2(item.caption_text, item.caption_entities) return entities_to_markdownv2(item.caption_text, item.caption_entities)
return standardize(item.caption_text) return standardize(item.caption_text)
+22 -17
View File
@@ -20,6 +20,7 @@ from app.chain.system import SystemChain
from app.core.config import global_vars, settings from app.core.config import global_vars, settings
from app.helper.server import MoviePilotServerHelper from app.helper.server import MoviePilotServerHelper
from app.helper.system import SystemHelper from app.helper.system import SystemHelper
from app.log import LoggerManager
from app.startup.command_initializer import init_command, stop_command, restart_command from app.startup.command_initializer import init_command, stop_command, restart_command
from app.startup.modules_initializer import init_modules, stop_modules from app.startup.modules_initializer import init_modules, stop_modules
from app.startup.monitor_initializer import stop_monitor, init_monitor from app.startup.monitor_initializer import stop_monitor, init_monitor
@@ -97,20 +98,24 @@ async def lifespan(app: FastAPI):
pass pass
except Exception as e: except Exception as e:
print(str(e)) print(str(e))
if not settings.MOVIEPILOT_SAFE_MODE: try:
# 备份插件 if not settings.MOVIEPILOT_SAFE_MODE:
SystemChain().backup_plugins() # 备份插件
# 停止工作流 SystemChain().backup_plugins()
stop_workflow() # 停止工作流
# 停止命令 stop_workflow()
stop_command() # 停止命令
# 停止监控器 stop_command()
stop_monitor() # 停止监控器
# 停止定时器 stop_monitor()
stop_scheduler() # 停止定时器
# 停止插件 stop_scheduler()
stop_plugins() # 停止插件
# 停止模块 stop_plugins()
await stop_modules() # 停止模块
# 关闭共享的异步 HTTP 连接池,释放底层连接资源 await stop_modules()
await aclose_shared_async_transports() # 关闭共享的异步 HTTP 连接池,释放底层连接资源
await aclose_shared_async_transports()
finally:
# 日志最后关闭,确保其他组件的收尾信息已写入文件
LoggerManager.shutdown()
+2
View File
@@ -137,6 +137,8 @@ async def stop_modules():
EventManager().stop() EventManager().stop()
# 停止虚拟显示 # 停止虚拟显示
DisplayHelper().stop() DisplayHelper().stop()
# 停止 DoH 服务
DohHelper().shutdown()
# 停止线程池 # 停止线程池
ThreadHelper().shutdown() ThreadHelper().shutdown()
# 停止消息服务 # 停止消息服务
+34 -8
View File
@@ -9,6 +9,8 @@ fixture 一并识别,autouse 自动作用于每个用例,无需逐用例改
""" """
from __future__ import annotations from __future__ import annotations
import ipaddress
import pytest import pytest
# 本地回环/通配地址放行,其余主机一律视为真实出站;getaddrinfo 的 host 可能为 str 或 bytes # 本地回环/通配地址放行,其余主机一律视为真实出站;getaddrinfo 的 host 可能为 str 或 bytes
@@ -20,21 +22,45 @@ def block_real_network(monkeypatch):
"""防御纵深:拦截对非本地主机的真实出站,强制测试零真实网络。 """防御纵深:拦截对非本地主机的真实出站,强制测试零真实网络。
补在各用例自身 mock 之上:某用例万一漏 mock 外部依赖(TMDB / LLM 目录 / 下载器 / 补在各用例自身 mock 之上:某用例万一漏 mock 外部依赖(TMDB / LLM 目录 / 下载器 /
媒体服务器 / 任意外链),其真实 DNS 解析会在此被拦并报错,而非静默发请求。本地回环放行 媒体服务器 / 任意外链),其 DNS 解析或 socket 连接会被拦截。本地回环放行(sqlite 等)。
sqlite 等)。asyncio 默认解析器经线程池调用 ``socket.getaddrinfo``,故拦此一处即覆盖 所有拦截记录会在用例收尾再次断言,避免业务代码捕获网络异常后让漏 mock 的用例静默通过。
同步与异步出站。``monkeypatch`` 在用例结束后自动还原,不影响其他用例与进程退出。 ``monkeypatch`` 在用例结束后自动还原,不影响其他用例与进程退出。
""" """
import socket import socket
_real_getaddrinfo = socket.getaddrinfo _real_getaddrinfo = socket.getaddrinfo
_real_connect = socket.socket.connect
attempts = []
def _is_allowed_host(host) -> bool:
normalized = host.decode() if isinstance(host, (bytes, bytearray)) else host
if normalized is None or normalized in _ALLOWED_NETWORK_HOSTS:
return True
try:
address = ipaddress.ip_address(str(normalized).split("%", 1)[0])
return address.is_loopback or address.is_unspecified
except ValueError:
return False
def _blocked(operation: str, host):
attempts.append((operation, host))
raise RuntimeError(
f"测试禁止真实出站网络:尝试通过 {operation} 访问 {host!r};请 mock 对应外部依赖"
)
def _guarded_getaddrinfo(host, *args, **kwargs): def _guarded_getaddrinfo(host, *args, **kwargs):
normalized = host.decode() if isinstance(host, (bytes, bytearray)) else host if not _is_allowed_host(host):
if normalized is not None and normalized not in _ALLOWED_NETWORK_HOSTS: _blocked("DNS", host)
raise RuntimeError(
f"测试禁止真实出站网络:尝试解析 {normalized!r};请 mock 对应外部依赖"
)
return _real_getaddrinfo(host, *args, **kwargs) return _real_getaddrinfo(host, *args, **kwargs)
def _guarded_connect(sock, address):
if isinstance(address, tuple) and address and not _is_allowed_host(address[0]):
_blocked("socket", address[0])
return _real_connect(sock, address)
monkeypatch.setattr(socket, "getaddrinfo", _guarded_getaddrinfo) monkeypatch.setattr(socket, "getaddrinfo", _guarded_getaddrinfo)
monkeypatch.setattr(socket.socket, "connect", _guarded_connect)
yield yield
if attempts:
details = ", ".join(f"{operation}:{host}" for operation, host in attempts)
pytest.fail(f"测试期间发生真实出站网络尝试:{details}")
+9
View File
@@ -10,6 +10,11 @@ class Singleton(abc.ABCMeta, type):
_instances: dict = {} _instances: dict = {}
def get_existing_instance(cls, *args, **kwargs):
"""按相同参数返回已创建实例,不触发初始化"""
key = (cls, args, frozenset(kwargs.items()))
return cls._instances.get(key)
def __call__(cls, *args, **kwargs): def __call__(cls, *args, **kwargs):
key = (cls, args, frozenset(kwargs.items())) key = (cls, args, frozenset(kwargs.items()))
if key not in cls._instances: if key not in cls._instances:
@@ -31,6 +36,10 @@ class SingletonClass(abc.ABCMeta, type):
_instances: dict = {} _instances: dict = {}
def get_existing_instance(cls):
"""返回已创建实例,不触发初始化"""
return cls._instances.get(cls)
def __call__(cls, *args, **kwargs): def __call__(cls, *args, **kwargs):
if cls not in cls._instances: if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs) cls._instances[cls] = super().__call__(*args, **kwargs)
-4
View File
@@ -7,9 +7,5 @@ timeout_method = thread
# 让本仓自身的新告警更醒目。本仓代码引发的告警一律不在此忽略,应在源码/用例处修复。 # 让本仓自身的新告警更醒目。本仓代码引发的告警一律不在此忽略,应在源码/用例处修复。
filterwarnings = filterwarnings =
ignore:datetime.datetime.utcfromtimestamp\(\) is deprecated:DeprecationWarning ignore:datetime.datetime.utcfromtimestamp\(\) is deprecated:DeprecationWarning
ignore:websockets.legacy is deprecated:DeprecationWarning
ignore:websockets.InvalidStatusCode is deprecated:DeprecationWarning
ignore:pkg_resources is deprecated as an API:DeprecationWarning
ignore:Deprecated call to .pkg_resources.declare_namespace:DeprecationWarning
ignore:'crypt' is deprecated:DeprecationWarning ignore:'crypt' is deprecated:DeprecationWarning
ignore:'audioop' is deprecated:DeprecationWarning ignore:'audioop' is deprecated:DeprecationWarning
+15 -7
View File
@@ -16,9 +16,11 @@ prepare_backend()
from app.testing.network_guard import block_real_network # noqa: E402,F401 from app.testing.network_guard import block_real_network # noqa: E402,F401
def _report_session_cleanup_error(name: str, err: Exception) -> None: def _report_session_cleanup_error(session, name: str, err: Exception) -> None:
"""测试收尾清理失败只记录诊断,不覆盖原始 pytest 退出状态""" """记录收尾错误;原测试绿色时将会话标记为失败"""
sys.stderr.write(f"\npytest session cleanup failed: {name}: {err!r}\n") sys.stderr.write(f"\npytest session cleanup failed: {name}: {err!r}\n")
if session.exitstatus == 0:
session.exitstatus = 1
def pytest_sessionfinish(session, exitstatus): def pytest_sessionfinish(session, exitstatus):
@@ -28,21 +30,27 @@ def pytest_sessionfinish(session, exitstatus):
shutdown_blocking_executors(cancel_futures=True) shutdown_blocking_executors(cancel_futures=True)
except Exception as err: except Exception as err:
_report_session_cleanup_error("agent blocking executors", err) _report_session_cleanup_error(session, "agent blocking executors", err)
try: try:
from app.helper.thread import ThreadHelper from app.helper.thread import ThreadHelper
from app.utils.singleton import Singleton
helper = Singleton._instances.get((ThreadHelper, (), frozenset())) helper = ThreadHelper.get_existing_instance()
if helper: if helper:
helper.shutdown() helper.shutdown()
except Exception as err: except Exception as err:
_report_session_cleanup_error("thread helper", err) _report_session_cleanup_error(session, "thread helper", err)
try:
from app.helper.message import stop_message
stop_message()
except Exception as err:
_report_session_cleanup_error(session, "message service", err)
try: try:
from app.log import LoggerManager from app.log import LoggerManager
LoggerManager.shutdown() LoggerManager.shutdown()
except Exception as err: except Exception as err:
_report_session_cleanup_error("logger manager", err) _report_session_cleanup_error(session, "logger manager", err)
+56
View File
@@ -3,6 +3,61 @@ import socket
from app.helper import doh from app.helper import doh
def test_doh_executor_is_lazy_and_shutdown_restores_socket(monkeypatch):
"""DoH 线程池按需创建,并在模块关闭时恢复系统 DNS"""
original_getaddrinfo = socket.getaddrinfo
helper = object.__new__(doh.DohHelper)
monkeypatch.setattr(doh.settings, "DOH_DOMAINS", "example.com")
monkeypatch.setattr(doh.settings, "DOH_RESOLVERS", "resolver.test")
monkeypatch.setattr(doh, "_doh_query", lambda resolver, host: "203.0.113.7")
monkeypatch.setattr(doh, "_orig_getaddrinfo", lambda host, *args, **kwargs: [])
try:
helper.shutdown()
assert doh._executor is None
doh.enable_doh(True)
socket.getaddrinfo("example.com", None)
executor = doh._executor
assert executor is not None
helper.shutdown()
assert doh._executor is None
assert socket.getaddrinfo is doh._orig_getaddrinfo
assert getattr(executor, "_shutdown", False)
finally:
helper.shutdown()
socket.getaddrinfo = original_getaddrinfo
def test_doh_config_reload_disables_and_closes_executor(monkeypatch):
"""热更新关闭 DoH 时恢复系统 DNS 并释放已创建的线程池"""
original_getaddrinfo = socket.getaddrinfo
helper = object.__new__(doh.DohHelper)
monkeypatch.setattr(doh.settings, "DOH_DOMAINS", "example.com")
monkeypatch.setattr(doh.settings, "DOH_RESOLVERS", "resolver.test")
monkeypatch.setattr(doh, "_doh_query", lambda resolver, host: "203.0.113.7")
monkeypatch.setattr(doh, "_orig_getaddrinfo", lambda host, *args, **kwargs: [])
try:
helper.shutdown()
doh.enable_doh(True)
socket.getaddrinfo("example.com", None)
executor = doh._executor
assert executor is not None
monkeypatch.setattr(doh.settings, "DOH_ENABLE", False)
helper.on_config_changed()
assert doh._executor is None
assert getattr(executor, "_shutdown", False)
assert socket.getaddrinfo is doh._orig_getaddrinfo
finally:
helper.shutdown()
socket.getaddrinfo = original_getaddrinfo
def test_enable_doh_reuses_cached_host_resolution(monkeypatch): def test_enable_doh_reuses_cached_host_resolution(monkeypatch):
""" """
同一 DoH 域名第二次解析应命中缓存,避免重复请求远端解析器。 同一 DoH 域名第二次解析应命中缓存,避免重复请求远端解析器。
@@ -33,6 +88,7 @@ def test_enable_doh_reuses_cached_host_resolution(monkeypatch):
socket.getaddrinfo("example.com", None) socket.getaddrinfo("example.com", None)
socket.getaddrinfo("example.com", None) socket.getaddrinfo("example.com", None)
finally: finally:
object.__new__(doh.DohHelper).shutdown()
socket.getaddrinfo = original_getaddrinfo socket.getaddrinfo = original_getaddrinfo
with doh._doh_lock: with doh._doh_lock:
doh._doh_cache.clear() doh._doh_cache.clear()
+1
View File
@@ -102,6 +102,7 @@ class EmbyDashboardLinksTest(unittest.TestCase):
with ( with (
patch.object(client, "_Emby__get_emby_librarys") as librarys, patch.object(client, "_Emby__get_emby_librarys") as librarys,
patch.object(client, "_Emby__get_local_image_by_id") as image_by_id, patch.object(client, "_Emby__get_local_image_by_id") as image_by_id,
patch.object(client, "get_items_count", return_value=0),
): ):
librarys.return_value = [ librarys.return_value = [
{ {
+46
View File
@@ -0,0 +1,46 @@
import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI
from app.startup import lifecycle
def test_lifespan_closes_logger_when_early_shutdown_step_fails(monkeypatch):
"""前置关闭步骤失败时仍应关闭 Logger"""
monkeypatch.setattr(lifecycle.settings, "MOVIEPILOT_SAFE_MODE", False)
monkeypatch.setattr(lifecycle.global_vars, "set_loop", MagicMock())
for name in (
"init_routers",
"init_modules",
"init_plugins",
"init_scheduler",
"init_monitor",
"init_command",
"init_workflow",
"stop_workflow",
"stop_command",
"stop_monitor",
"stop_scheduler",
"stop_plugins",
):
monkeypatch.setattr(lifecycle, name, MagicMock())
system_chain = MagicMock()
system_chain.backup_plugins.side_effect = RuntimeError("backup failed")
monkeypatch.setattr(lifecycle, "SystemChain", MagicMock(return_value=system_chain))
monkeypatch.setattr(lifecycle, "init_extra", AsyncMock())
monkeypatch.setattr(lifecycle, "stop_modules", AsyncMock())
monkeypatch.setattr(lifecycle, "aclose_shared_async_transports", AsyncMock())
logger_shutdown = MagicMock()
monkeypatch.setattr(lifecycle.LoggerManager, "shutdown", logger_shutdown)
async def run_lifespan():
with pytest.raises(RuntimeError, match="backup failed"):
async with lifecycle.lifespan(FastAPI()):
pass
asyncio.run(run_lifespan())
logger_shutdown.assert_called_once_with()
+149
View File
@@ -0,0 +1,149 @@
import threading
import time
from unittest.mock import MagicMock
from app.log import LogEntry, NonBlockingFileHandler, log_settings
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.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
+10
View File
@@ -20,6 +20,16 @@ def clear_media_interactions():
plugin_input_interaction_manager.clear() plugin_input_interaction_manager.clear()
@pytest.fixture(autouse=True)
def mock_default_media_search():
"""未显式验证搜索结果的消息路由用例不访问真实媒体元数据服务"""
with patch(
"app.chain.media.MediaChain.search",
side_effect=lambda title: (_build_meta(title), []),
):
yield
def _build_meta(name: str) -> MetaBase: def _build_meta(name: str) -> MetaBase:
"""构造媒体识别元数据。""" """构造媒体识别元数据。"""
meta = MetaBase(name) meta = MetaBase(name)
+30
View File
@@ -0,0 +1,30 @@
import time
from app.helper.message import MessageQueueManager, TemplateHelper, stop_message
from app.utils.singleton import SingletonClass
def test_message_queue_stop_wakes_idle_monitor(monkeypatch):
"""消息队列停止时应唤醒空闲监控线程,不等待完整检查周期"""
monkeypatch.setattr(MessageQueueManager, "init_config", lambda self: None)
manager = object.__new__(MessageQueueManager)
manager.__init__(check_interval=10)
started_at = time.monotonic()
manager.stop()
elapsed = time.monotonic() - started_at
assert elapsed < 1
assert not manager.thread.is_alive()
def test_stop_message_does_not_initialize_absent_services(monkeypatch):
"""消息服务未初始化时,关闭入口不应为了清理而创建后台资源"""
monkeypatch.setattr(SingletonClass, "_instances", {})
assert MessageQueueManager.get_existing_instance() is None
assert TemplateHelper.get_existing_instance() is None
stop_message()
assert MessageQueueManager not in SingletonClass._instances
assert TemplateHelper not in SingletonClass._instances
+22
View File
@@ -0,0 +1,22 @@
import socket
import pytest
from app.testing.network_guard import block_real_network
def test_network_guard_fails_when_blocked_attempt_is_swallowed(monkeypatch):
"""业务代码即使捕获网络异常,网络守卫仍应在用例收尾报告失败"""
fixture = block_real_network.__wrapped__(monkeypatch)
next(fixture)
try:
try:
socket.getaddrinfo("external.example", 443)
except RuntimeError:
pass
with pytest.raises(pytest.fail.Exception, match="external.example"):
next(fixture)
finally:
monkeypatch.undo()
+29
View File
@@ -0,0 +1,29 @@
from app.utils.singleton import Singleton, SingletonClass
def test_singleton_class_can_read_existing_instance_without_creating(monkeypatch):
"""按类单例可以只读取已存在实例"""
class Example(metaclass=SingletonClass):
pass
monkeypatch.setattr(SingletonClass, "_instances", {})
assert Example.get_existing_instance() is None
instance = Example()
assert Example.get_existing_instance() is instance
def test_parameterized_singleton_can_read_matching_instance_without_creating(monkeypatch):
"""参数化单例按相同参数读取已存在实例"""
class Example(metaclass=Singleton):
def __init__(self, name):
self.name = name
monkeypatch.setattr(Singleton, "_instances", {})
assert Example.get_existing_instance("first") is None
instance = Example("first")
assert Example.get_existing_instance("first") is instance
assert Example.get_existing_instance("second") is None
+2 -1
View File
@@ -362,7 +362,7 @@ class SubscribeEndpointTest(TestCase):
with patch( with patch(
"app.api.endpoints.subscribe.Subscribe.async_list_by_username", "app.api.endpoints.subscribe.Subscribe.async_list_by_username",
new=AsyncMock(return_value=owned), new=AsyncMock(return_value=owned),
): ), patch("app.api.endpoints.subscribe.Scheduler") as scheduler_cls:
response = asyncio.run( response = asyncio.run(
search_subscribes( search_subscribes(
background_tasks=background_tasks, background_tasks=background_tasks,
@@ -376,6 +376,7 @@ class SubscribeEndpointTest(TestCase):
[task["kwargs"]["sid"] for task in background_tasks.tasks], [task["kwargs"]["sid"] for task in background_tasks.tasks],
[17, 18], [17, 18],
) )
self.assertEqual(scheduler_cls.return_value.start.call_count, 0)
def test_subscribe_files_hides_other_user_row(self): def test_subscribe_files_hides_other_user_row(self):
""" """
+23 -6
View File
@@ -3,6 +3,7 @@
Telegram 模块单元测试(pytest 原生)。 Telegram 模块单元测试(pytest 原生)。
""" """
import json import json
import warnings
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch from unittest.mock import MagicMock, Mock, patch
@@ -286,13 +287,29 @@ def test_send_msg_markdown_escaping(telegram):
assert send_kwargs["text"].startswith("*测试标题*\n") assert send_kwargs["text"].startswith("*测试标题*\n")
def test_telegramify_new_content_fields_are_used_directly(): def test_telegramify_current_fields_are_used_directly():
"""新版telegramify对象直接使用已渲染的MarkdownV2字段""" """telegramify 对象直接使用当前 MarkdownV2 字段"""
text_item = SimpleNamespace(content="已转义\\_文本") from telegramify_markdown.content import ContentTrace, File, Text
file_item = SimpleNamespace(caption="已转义\\_说明")
assert Telegram._telegramify_item_text(text_item) == "已转义\\_文本" text_item = Text(
assert Telegram._telegramify_item_caption(file_item) == "已转义\\_说明" text="已转义_文本",
entities=[],
content_trace=ContentTrace(source_type="test"),
)
file_item = File(
file_name="test.txt",
file_data=b"test",
caption_text="已转义_说明",
caption_entities=[],
content_trace=ContentTrace(source_type="test"),
)
with warnings.catch_warnings(record=True) as warning_records:
warnings.simplefilter("always")
assert Telegram._telegramify_item_text(text_item) == "已转义\\_文本"
assert Telegram._telegramify_item_caption(file_item) == "已转义\\_说明"
assert not warning_records
def test_send_msg_with_html_parse_mode_keeps_html(telegram): def test_send_msg_with_html_parse_mode_keeps_html(telegram):
+2 -1
View File
@@ -183,7 +183,8 @@ def test_build_web_agent_command_items_returns_slash_commands():
def test_build_web_agent_command_items_includes_sites_command(): def test_build_web_agent_command_items_includes_sites_command():
"""WebAgent 命令建议应包含内建站点管理命令。""" """WebAgent 命令建议应包含内建站点管理命令。"""
commands = _build_web_agent_command_items() with patch("app.command.Scheduler"), patch("app.command.ThreadHelper"):
commands = _build_web_agent_command_items()
assert any(command["command"] == "/sites" for command in commands) assert any(command["command"] == "/sites" for command in commands)