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.singleton import Singleton
# 定义一个全局线程池执行器
_executor = concurrent.futures.ThreadPoolExecutor()
# DoH 关闭时需要释放线程池;保持惰性创建可避免未启用 DoH 时占用进程级资源
_executor: Optional[concurrent.futures.ThreadPoolExecutor] = None
_executor_lock = Lock()
_doh_enabled = False
# 定义默认的DoH配置
_doh_timeout = 5
@@ -29,11 +31,21 @@ _doh_lock = Lock()
_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:
"""
对 socket.getaddrinfo 进行补丁
"""
global _doh_enabled
def _patched_getaddrinfo(host: str, *args, **kwargs):
"""
socket.getaddrinfo的补丁版本。
@@ -47,9 +59,15 @@ def enable_doh(enable: bool) -> None:
logger.info(f"已解析 [{host}] 为 [{ip}] (缓存)")
return _orig_getaddrinfo(ip, *args, **kwargs)
# 使用DoH解析主机
futures = []
for resolver in settings.DOH_RESOLVERS.split(","):
futures.append(_executor.submit(_doh_query, resolver, host))
with _executor_lock:
if not _doh_enabled:
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):
ip = future.result()
if ip is not None:
@@ -60,11 +78,9 @@ def enable_doh(enable: bool) -> None:
break
return _orig_getaddrinfo(host, *args, **kwargs)
if enable:
# 替换 socket.getaddrinfo 方法
socket.getaddrinfo = _patched_getaddrinfo
else:
socket.getaddrinfo = _orig_getaddrinfo
with _executor_lock:
_doh_enabled = enable
socket.getaddrinfo = _patched_getaddrinfo if enable else _orig_getaddrinfo
class DohHelper(ConfigReloadMixin, metaclass=Singleton):
@@ -77,14 +93,31 @@ class DohHelper(ConfigReloadMixin, metaclass=Singleton):
enable_doh(settings.DOH_ENABLE)
def on_config_changed(self) -> None:
if not settings.DOH_ENABLE:
self.shutdown()
return
with _doh_lock:
# DOH配置有变动的情况下,清空缓存
_doh_cache.clear()
enable_doh(settings.DOH_ENABLE)
enable_doh(True)
def get_reload_name(self) -> str:
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]:
"""
使用给定的DoH解析器查询给定主机的IP地址。
+9 -5
View File
@@ -605,6 +605,7 @@ class MessageQueueManager(metaclass=SingletonClass):
self.check_interval = check_interval
self._running = True
self._stop_event = threading.Event()
self.thread = threading.Thread(target=self._monitor_loop, daemon=True)
self.thread.start()
@@ -752,13 +753,15 @@ class MessageQueueManager(metaclass=SingletonClass):
logger.info(f"队列剩余消息:{self.queue.qsize()}")
except queue.Empty:
break
time.sleep(self.check_interval)
if self._stop_event.wait(self.check_interval):
break
def stop(self) -> None:
"""
停止队列管理器
"""
self._running = False
self._stop_event.set()
logger.info("正在停止消息队列...")
self.thread.join()
logger.info("消息队列已停止")
@@ -841,7 +844,8 @@ def stop_message():
"""
停止消息服务
"""
# 停止消息队列
MessageQueueManager().stop()
# 关闭消息演染器
TemplateHelper().close()
# 只关闭已启动的服务,避免清理路径反向创建后台线程和缓存
if queue_manager := MessageQueueManager.get_existing_instance():
queue_manager.stop()
if template_helper := TemplateHelper.get_existing_instance():
template_helper.close()
+58 -35
View File
@@ -124,7 +124,7 @@ class NonBlockingFileHandler:
"""
_instance = None
_lock = threading.Lock()
_rotating_handlers = {}
_stop_sentinel = object()
def __new__(cls):
if cls._instance is None:
@@ -138,6 +138,9 @@ class NonBlockingFileHandler:
return
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._executor = ThreadPoolExecutor(max_workers=log_settings.ASYNC_FILE_WORKERS,
thread_name_prefix="LogWriter")
@@ -151,27 +154,28 @@ class NonBlockingFileHandler:
"""
获取或创建RotatingFileHandler实例
"""
if file_path not in self._rotating_handlers:
# 确保目录存在
file_path.parent.mkdir(parents=True, exist_ok=True)
with self._handlers_lock:
if file_path not in self._rotating_handlers:
# 确保目录存在
file_path.parent.mkdir(parents=True, exist_ok=True)
# 创建RotatingFileHandler
handler = RotatingFileHandler(
filename=str(file_path),
maxBytes=log_settings.LOG_MAX_FILE_SIZE_BYTES,
backupCount=log_settings.LOG_BACKUP_COUNT,
encoding='utf-8'
)
# 创建RotatingFileHandler
handler = RotatingFileHandler(
filename=str(file_path),
maxBytes=log_settings.LOG_MAX_FILE_SIZE_BYTES,
backupCount=log_settings.LOG_BACKUP_COUNT,
encoding='utf-8'
)
# 设置格式化器
formatter = logging.Formatter(log_settings.LOG_FILE_FORMAT)
handler.setFormatter(formatter)
# 设置格式化器
formatter = logging.Formatter(log_settings.LOG_FILE_FORMAT)
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():
# 在协程环境中,使用非阻塞方式
self._write_non_blocking(entry)
else:
# 不在协程环境中,直接同步写入
return
with self._state_lock:
if not self._running:
return
# 不在协程环境中,持锁同步写入,避免关闭文件处理器时仍有写操作进行
self._write_sync(entry)
@staticmethod
@@ -196,15 +203,19 @@ class NonBlockingFileHandler:
except RuntimeError:
return False
def _write_non_blocking(self, entry: LogEntry):
def _write_non_blocking(self, entry: LogEntry) -> bool:
"""
非阻塞写入(用于协程环境)
"""
try:
self._write_queue.put_nowait(entry)
except queue.Full:
# 队列满时,使用线程池处理
self._executor.submit(self._write_sync, entry)
with self._state_lock:
if not self._running:
return False
try:
self._write_queue.put_nowait(entry)
except queue.Full:
# 队列满时,使用线程池处理
self._executor.submit(self._write_sync, entry)
return True
@staticmethod
def _write_sync(entry: LogEntry):
@@ -215,8 +226,7 @@ class NonBlockingFileHandler:
# 获取RotatingFileHandler实例
handler = NonBlockingFileHandler()._get_rotating_handler(entry.file_path)
# 使用RotatingFileHandler的emit方法,只传递原始消息
handler.emit(logging.LogRecord(
handler.handle(logging.LogRecord(
name='',
level=getattr(logging, entry.level.upper(), logging.INFO),
pathname='',
@@ -235,22 +245,28 @@ class NonBlockingFileHandler:
"""
后台批量写入线程
"""
while self._running:
while True:
try:
# 收集一批日志条目
batch = []
should_stop = False
end_time = time.time() + log_settings.WRITE_TIMEOUT
while len(batch) < log_settings.BATCH_WRITE_SIZE and time.time() < end_time:
try:
remaining_time = max(0, end_time - time.time())
entry = self._write_queue.get(timeout=remaining_time)
if entry is self._stop_sentinel:
should_stop = True
break
batch.append(entry)
except queue.Empty:
break
if batch:
self._write_batch(batch)
if should_stop:
break
except Exception as e:
print(f"批量写入线程错误: {e}")
@@ -275,8 +291,7 @@ class NonBlockingFileHandler:
# 批量写入
for entry in entries:
# 使用RotatingFileHandler的emit方法,只传递原始消息
handler.emit(logging.LogRecord(
handler.handle(logging.LogRecord(
name='',
level=getattr(logging, entry.level.upper(), logging.INFO),
pathname='',
@@ -294,15 +309,23 @@ class NonBlockingFileHandler:
def shutdown(self):
"""
关闭文件处理器
排空异步日志并关闭文件处理器
"""
self._running = False
if hasattr(self, '_write_thread'):
self._write_thread.join(timeout=5)
with self._state_lock:
if not self._running:
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:
self._executor.shutdown(wait=True)
# 清理缓存
for handler in self._rotating_handlers.values():
handler.flush()
handler.close()
self._rotating_handlers.clear()
-4
View File
@@ -279,8 +279,6 @@ class Telegram:
@staticmethod
def _telegramify_item_text(item: Text) -> str:
"""将 telegramify 文本片段转换为 Telegram MarkdownV2 字符串。"""
if hasattr(item, "content"):
return item.content
if entities_to_markdownv2:
return entities_to_markdownv2(item.text, item.entities)
return standardize(item.text)
@@ -290,8 +288,6 @@ class Telegram:
"""将 telegramify 文本或媒体片段转换为 Telegram MarkdownV2 caption。"""
if isinstance(item, Text):
return Telegram._telegramify_item_text(item)
if hasattr(item, "caption"):
return item.caption
if entities_to_markdownv2:
return entities_to_markdownv2(item.caption_text, item.caption_entities)
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.helper.server import MoviePilotServerHelper
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.modules_initializer import init_modules, stop_modules
from app.startup.monitor_initializer import stop_monitor, init_monitor
@@ -97,20 +98,24 @@ async def lifespan(app: FastAPI):
pass
except Exception as e:
print(str(e))
if not settings.MOVIEPILOT_SAFE_MODE:
# 备份插件
SystemChain().backup_plugins()
# 停止工作流
stop_workflow()
# 停止命令
stop_command()
# 停止监控器
stop_monitor()
# 停止定时器
stop_scheduler()
# 停止插件
stop_plugins()
# 停止模块
await stop_modules()
# 关闭共享的异步 HTTP 连接池,释放底层连接资源
await aclose_shared_async_transports()
try:
if not settings.MOVIEPILOT_SAFE_MODE:
# 备份插件
SystemChain().backup_plugins()
# 停止工作流
stop_workflow()
# 停止命令
stop_command()
# 停止监控器
stop_monitor()
# 停止定时器
stop_scheduler()
# 停止插件
stop_plugins()
# 停止模块
await stop_modules()
# 关闭共享的异步 HTTP 连接池,释放底层连接资源
await aclose_shared_async_transports()
finally:
# 日志最后关闭,确保其他组件的收尾信息已写入文件
LoggerManager.shutdown()
+2
View File
@@ -137,6 +137,8 @@ async def stop_modules():
EventManager().stop()
# 停止虚拟显示
DisplayHelper().stop()
# 停止 DoH 服务
DohHelper().shutdown()
# 停止线程池
ThreadHelper().shutdown()
# 停止消息服务
+34 -8
View File
@@ -9,6 +9,8 @@ fixture 一并识别,autouse 自动作用于每个用例,无需逐用例改
"""
from __future__ import annotations
import ipaddress
import pytest
# 本地回环/通配地址放行,其余主机一律视为真实出站;getaddrinfo 的 host 可能为 str 或 bytes
@@ -20,21 +22,45 @@ def block_real_network(monkeypatch):
"""防御纵深:拦截对非本地主机的真实出站,强制测试零真实网络。
补在各用例自身 mock 之上:某用例万一漏 mock 外部依赖(TMDB / LLM 目录 / 下载器 /
媒体服务器 / 任意外链),其真实 DNS 解析会在此被拦并报错,而非静默发请求。本地回环放行
sqlite 等)。asyncio 默认解析器经线程池调用 ``socket.getaddrinfo``,故拦此一处即覆盖
同步与异步出站。``monkeypatch`` 在用例结束后自动还原,不影响其他用例与进程退出。
媒体服务器 / 任意外链),其 DNS 解析或 socket 连接会被拦截。本地回环放行(sqlite 等)。
所有拦截记录会在用例收尾再次断言,避免业务代码捕获网络异常后让漏 mock 的用例静默通过。
``monkeypatch`` 在用例结束后自动还原,不影响其他用例与进程退出。
"""
import socket
_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):
normalized = host.decode() if isinstance(host, (bytes, bytearray)) else host
if normalized is not None and normalized not in _ALLOWED_NETWORK_HOSTS:
raise RuntimeError(
f"测试禁止真实出站网络:尝试解析 {normalized!r};请 mock 对应外部依赖"
)
if not _is_allowed_host(host):
_blocked("DNS", host)
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.socket, "connect", _guarded_connect)
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 = {}
def get_existing_instance(cls, *args, **kwargs):
"""按相同参数返回已创建实例,不触发初始化"""
key = (cls, args, frozenset(kwargs.items()))
return cls._instances.get(key)
def __call__(cls, *args, **kwargs):
key = (cls, args, frozenset(kwargs.items()))
if key not in cls._instances:
@@ -31,6 +36,10 @@ class SingletonClass(abc.ABCMeta, type):
_instances: dict = {}
def get_existing_instance(cls):
"""返回已创建实例,不触发初始化"""
return cls._instances.get(cls)
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
-4
View File
@@ -7,9 +7,5 @@ timeout_method = thread
# 让本仓自身的新告警更醒目。本仓代码引发的告警一律不在此忽略,应在源码/用例处修复。
filterwarnings =
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:'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
def _report_session_cleanup_error(name: str, err: Exception) -> None:
"""测试收尾清理失败只记录诊断,不覆盖原始 pytest 退出状态"""
def _report_session_cleanup_error(session, name: str, err: Exception) -> None:
"""记录收尾错误;原测试绿色时将会话标记为失败"""
sys.stderr.write(f"\npytest session cleanup failed: {name}: {err!r}\n")
if session.exitstatus == 0:
session.exitstatus = 1
def pytest_sessionfinish(session, exitstatus):
@@ -28,21 +30,27 @@ def pytest_sessionfinish(session, exitstatus):
shutdown_blocking_executors(cancel_futures=True)
except Exception as err:
_report_session_cleanup_error("agent blocking executors", err)
_report_session_cleanup_error(session, "agent blocking executors", err)
try:
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:
helper.shutdown()
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:
from app.log import LoggerManager
LoggerManager.shutdown()
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
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):
"""
同一 DoH 域名第二次解析应命中缓存,避免重复请求远端解析器。
@@ -33,6 +88,7 @@ def test_enable_doh_reuses_cached_host_resolution(monkeypatch):
socket.getaddrinfo("example.com", None)
socket.getaddrinfo("example.com", None)
finally:
object.__new__(doh.DohHelper).shutdown()
socket.getaddrinfo = original_getaddrinfo
with doh._doh_lock:
doh._doh_cache.clear()
+1
View File
@@ -102,6 +102,7 @@ class EmbyDashboardLinksTest(unittest.TestCase):
with (
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, "get_items_count", return_value=0),
):
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()
@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:
"""构造媒体识别元数据。"""
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(
"app.api.endpoints.subscribe.Subscribe.async_list_by_username",
new=AsyncMock(return_value=owned),
):
), patch("app.api.endpoints.subscribe.Scheduler") as scheduler_cls:
response = asyncio.run(
search_subscribes(
background_tasks=background_tasks,
@@ -376,6 +376,7 @@ class SubscribeEndpointTest(TestCase):
[task["kwargs"]["sid"] for task in background_tasks.tasks],
[17, 18],
)
self.assertEqual(scheduler_cls.return_value.start.call_count, 0)
def test_subscribe_files_hides_other_user_row(self):
"""
+23 -6
View File
@@ -3,6 +3,7 @@
Telegram 模块单元测试(pytest 原生)。
"""
import json
import warnings
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch
@@ -286,13 +287,29 @@ def test_send_msg_markdown_escaping(telegram):
assert send_kwargs["text"].startswith("*测试标题*\n")
def test_telegramify_new_content_fields_are_used_directly():
"""新版telegramify对象直接使用已渲染的MarkdownV2字段"""
text_item = SimpleNamespace(content="已转义\\_文本")
file_item = SimpleNamespace(caption="已转义\\_说明")
def test_telegramify_current_fields_are_used_directly():
"""telegramify 对象直接使用当前 MarkdownV2 字段"""
from telegramify_markdown.content import ContentTrace, File, Text
assert Telegram._telegramify_item_text(text_item) == "已转义\\_文本"
assert Telegram._telegramify_item_caption(file_item) == "已转义\\_说明"
text_item = Text(
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):
+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():
"""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)