mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-19 05:03:57 +08:00
522 lines
18 KiB
Python
522 lines
18 KiB
Python
import asyncio
|
||
import signal
|
||
import threading
|
||
from unittest.mock import AsyncMock, MagicMock
|
||
|
||
import pytest
|
||
from fastapi import FastAPI
|
||
|
||
from app.startup import lifecycle, modules_initializer
|
||
from app.adapters.network import http as http_utils
|
||
|
||
|
||
def _assert_completed_once(mock: MagicMock) -> None:
|
||
if isinstance(mock, AsyncMock):
|
||
mock.assert_awaited_once_with()
|
||
else:
|
||
mock.assert_called_once_with()
|
||
|
||
|
||
def _patch_lifespan(monkeypatch, *, failing_step: str | None = None) -> dict:
|
||
"""隔离 lifespan 的外部依赖,并按名称注入一个关闭失败"""
|
||
monkeypatch.setattr(lifecycle.settings, "MOVIEPILOT_SAFE_MODE", False)
|
||
monkeypatch.setattr(lifecycle.global_vars, "set_loop", MagicMock())
|
||
monkeypatch.setattr(lifecycle.global_vars, "stop_system", MagicMock())
|
||
|
||
for name in (
|
||
"init_routers",
|
||
"init_plugins",
|
||
"init_scheduler",
|
||
"init_monitor",
|
||
"init_command",
|
||
"init_workflow",
|
||
):
|
||
monkeypatch.setattr(lifecycle, name, MagicMock())
|
||
monkeypatch.setattr(lifecycle, "init_modules", AsyncMock())
|
||
|
||
# 启动期的引擎预热与额度核算也要打桩。不打的话这些用例会走真实的引擎创建,在测试
|
||
# 进程里留下一个从此无人释放的全局异步引擎——NullPool 不持连接、无害,但用例就不再
|
||
# 自洽了,而且额度核算还会去连库。
|
||
for name in ("get_engine", "get_global_async_engine", "check_connection_budget"):
|
||
monkeypatch.setattr(lifecycle, name, MagicMock())
|
||
|
||
system_chain = MagicMock()
|
||
monkeypatch.setattr(lifecycle, "SystemChain", MagicMock(return_value=system_chain))
|
||
monkeypatch.setattr(lifecycle, "init_extra", AsyncMock())
|
||
|
||
shutdown_steps = {
|
||
"backup_plugins": system_chain.backup_plugins,
|
||
"stop_workflow": MagicMock(),
|
||
"stop_command": MagicMock(),
|
||
"stop_monitor": MagicMock(),
|
||
"stop_scheduler": MagicMock(),
|
||
"stop_plugins": MagicMock(),
|
||
"stop_modules": AsyncMock(),
|
||
"close_http": AsyncMock(),
|
||
}
|
||
for name in (
|
||
"stop_workflow",
|
||
"stop_command",
|
||
"stop_monitor",
|
||
"stop_scheduler",
|
||
"stop_plugins",
|
||
):
|
||
monkeypatch.setattr(lifecycle, name, shutdown_steps[name])
|
||
monkeypatch.setattr(lifecycle, "stop_modules", shutdown_steps["stop_modules"])
|
||
monkeypatch.setattr(
|
||
lifecycle,
|
||
"aclose_shared_async_transports",
|
||
shutdown_steps["close_http"],
|
||
)
|
||
|
||
if failing_step:
|
||
shutdown_steps[failing_step].side_effect = RuntimeError(
|
||
f"{failing_step} failed"
|
||
)
|
||
|
||
logger_shutdown = MagicMock()
|
||
monkeypatch.setattr(lifecycle.LoggerManager, "shutdown", logger_shutdown)
|
||
shutdown_steps["logger"] = logger_shutdown
|
||
return shutdown_steps
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"failing_step",
|
||
[
|
||
"backup_plugins",
|
||
"stop_workflow",
|
||
"stop_command",
|
||
"stop_monitor",
|
||
"stop_scheduler",
|
||
"stop_plugins",
|
||
"stop_modules",
|
||
"close_http",
|
||
],
|
||
)
|
||
def test_lifespan_continues_after_each_shutdown_owner_failure(
|
||
monkeypatch,
|
||
failing_step,
|
||
):
|
||
"""任一关闭阶段失败都不能跳过后续资源所有者"""
|
||
shutdown_steps = _patch_lifespan(monkeypatch, failing_step=failing_step)
|
||
|
||
async def run_lifespan():
|
||
async with lifecycle.lifespan(FastAPI()):
|
||
pass
|
||
|
||
asyncio.run(run_lifespan())
|
||
|
||
lifecycle.global_vars.stop_system.assert_called_once_with()
|
||
lifecycle.init_modules.assert_awaited_once_with()
|
||
for step in shutdown_steps.values():
|
||
_assert_completed_once(step)
|
||
|
||
|
||
def test_lifespan_creates_global_async_engine_at_startup(monkeypatch):
|
||
"""启动期必须把全局异步引擎建出来一次,让异步侧恢复 fail-fast
|
||
|
||
引擎改为惰性创建后,启动路径只碰得到同步引擎(init_db 建表),异步驱动没装、
|
||
异步 URL 拼错这类问题会一路推迟到第一个异步查询——用户拿到 500、调度任务静默死掉,
|
||
而不是启动就崩。create_async_engine 只校验 URL 与驱动导入、不建立连接,代价可以忽略。
|
||
"""
|
||
_patch_lifespan(monkeypatch)
|
||
created = []
|
||
monkeypatch.setattr(lifecycle, "get_global_async_engine",
|
||
lambda: created.append(1) or MagicMock())
|
||
|
||
async def run_lifespan():
|
||
async with lifecycle.lifespan(FastAPI()):
|
||
pass
|
||
|
||
asyncio.run(run_lifespan())
|
||
|
||
assert created, "启动期未创建全局异步引擎,异步侧的驱动/URL 错误会推迟到运行期才暴露"
|
||
|
||
|
||
def test_lifespan_creates_sync_engine_at_startup(monkeypatch):
|
||
"""启动期也必须把同步引擎建出来一次,把首次创建钉在单线程期
|
||
|
||
「init_db() 会在启动期单线程预热同步引擎」这个前提只对 run_application() 入口成立。
|
||
外部 supervisor 直挂 ASGI app(`gunicorn -k uvicorn.workers.UvicornWorker
|
||
app.factory:app`、`uvicorn app.main:app`)时 run_application() 不执行、init_db() 也就
|
||
不执行,同步引擎的首次创建退到运行期——而那时 init_scheduler() / init_monitor() 已经
|
||
放出上百个线程,引擎构建里那段 PRAGMA journal_mode 会让它们一起堵在创建锁上。
|
||
"""
|
||
_patch_lifespan(monkeypatch)
|
||
created = []
|
||
monkeypatch.setattr(lifecycle, "get_engine",
|
||
lambda: created.append(1) or MagicMock())
|
||
|
||
async def run_lifespan():
|
||
async with lifecycle.lifespan(FastAPI()):
|
||
pass
|
||
|
||
asyncio.run(run_lifespan())
|
||
|
||
assert created, "启动期未预热同步引擎,首次创建会退到已经放出上百个线程的运行期"
|
||
|
||
|
||
def test_lifespan_warms_engines_before_any_initializer(monkeypatch):
|
||
"""两个引擎的预热必须排在 init_routers / init_modules 之前
|
||
|
||
排在后面时,预热失败会把已经初始化好的模块晾在那里:lifespan 的 try/finally 关停块
|
||
要到 yield 处才开始,在它之前抛异常,stop_modules() 根本没有机会执行。
|
||
"""
|
||
_patch_lifespan(monkeypatch)
|
||
calls = []
|
||
monkeypatch.setattr(lifecycle, "get_engine", lambda: calls.append("sync_engine"))
|
||
monkeypatch.setattr(lifecycle, "get_global_async_engine",
|
||
lambda: calls.append("async_engine"))
|
||
monkeypatch.setattr(lifecycle, "init_routers", lambda _app: calls.append("init_routers"))
|
||
async def _init_modules():
|
||
"""init_modules 在 v3 是协程,桩也必须可 await。"""
|
||
calls.append("init_modules")
|
||
|
||
monkeypatch.setattr(lifecycle, "init_modules", _init_modules)
|
||
|
||
async def run_lifespan():
|
||
async with lifecycle.lifespan(FastAPI()):
|
||
pass
|
||
|
||
asyncio.run(run_lifespan())
|
||
|
||
# 不钉同步/异步两者之间的先后:那一层顺序无所谓,要紧的是它们都在 init_* 之前
|
||
assert set(calls[:2]) == {"sync_engine", "async_engine"}, f"引擎预热没有排在最前面:{calls}"
|
||
assert calls[2:] == ["init_routers", "init_modules"], f"初始化顺序被打乱:{calls}"
|
||
|
||
|
||
def test_lifespan_fails_fast_when_async_engine_cannot_be_built(monkeypatch):
|
||
"""异步引擎建不起来必须让启动直接失败,不能吞掉继续跑
|
||
|
||
吞掉等于把 fail-fast 又还回去了:进程起来了、健康检查是绿的,只有异步请求在报错。
|
||
"""
|
||
_patch_lifespan(monkeypatch)
|
||
|
||
def _boom():
|
||
"""模拟异步驱动缺失。"""
|
||
raise RuntimeError("no async driver")
|
||
|
||
monkeypatch.setattr(lifecycle, "get_global_async_engine", _boom)
|
||
|
||
async def run_lifespan():
|
||
async with lifecycle.lifespan(FastAPI()):
|
||
pass
|
||
|
||
with pytest.raises(RuntimeError, match="no async driver"):
|
||
asyncio.run(run_lifespan())
|
||
|
||
# 失败要发生在任何东西被初始化之前,否则模块起来了却没人关:关停块在 yield 处才开始
|
||
lifecycle.init_routers.assert_not_called()
|
||
lifecycle.init_modules.assert_not_called()
|
||
|
||
|
||
def test_uvicorn_signal_publishes_stop_before_server_exit(monkeypatch):
|
||
"""Uvicorn 接管系统信号时必须先发布协作停止标志"""
|
||
from app import main
|
||
|
||
calls = []
|
||
monkeypatch.setattr(main.global_vars, "stop_system", lambda: calls.append("stop"))
|
||
monkeypatch.setattr(
|
||
main.uvicorn.Server,
|
||
"handle_exit",
|
||
lambda _self, _sig, _frame: calls.append("uvicorn"),
|
||
)
|
||
|
||
server = object.__new__(main.MoviePilotServer)
|
||
server.handle_exit(signal.SIGTERM, None)
|
||
|
||
assert calls == ["stop", "uvicorn"]
|
||
|
||
|
||
def test_application_preserves_stop_requested_before_startup(monkeypatch):
|
||
"""启动流程不能清除初始化前已经发布的退出请求"""
|
||
from app import main
|
||
|
||
stop_event = threading.Event()
|
||
stop_event.set()
|
||
monkeypatch.setattr(main.global_vars, "STOP_EVENT", stop_event)
|
||
calls = []
|
||
monkeypatch.setattr(
|
||
main.signal,
|
||
"signal",
|
||
lambda *_args: calls.append("signal"),
|
||
)
|
||
monkeypatch.setattr(main, "start_tray", lambda: calls.append("tray"))
|
||
monkeypatch.setattr(main, "init_db", lambda: calls.append("init_db"))
|
||
monkeypatch.setattr(main, "update_db", lambda: calls.append("update_db"))
|
||
monkeypatch.setattr(main.Server, "run", lambda: calls.append("server"))
|
||
|
||
main.run_application()
|
||
|
||
assert stop_event.is_set()
|
||
assert calls == [
|
||
"signal",
|
||
"signal",
|
||
"tray",
|
||
"init_db",
|
||
"update_db",
|
||
"server",
|
||
]
|
||
|
||
|
||
def test_application_does_not_start_server_after_migration_failure(monkeypatch):
|
||
"""数据库迁移失败时不得启动 API 服务。"""
|
||
from app import main
|
||
|
||
migration_error = RuntimeError("migration failed")
|
||
server_run = MagicMock()
|
||
monkeypatch.setattr(main.signal, "signal", MagicMock())
|
||
monkeypatch.setattr(main, "start_tray", MagicMock())
|
||
monkeypatch.setattr(main, "init_db", MagicMock())
|
||
monkeypatch.setattr(main, "update_db", MagicMock(side_effect=migration_error))
|
||
monkeypatch.setattr(main.Server, "run", server_run)
|
||
|
||
with pytest.raises(RuntimeError) as raised:
|
||
main.run_application()
|
||
|
||
assert raised.value is migration_error
|
||
server_run.assert_not_called()
|
||
|
||
|
||
def test_uvicorn_preserves_stop_requested_before_serve(monkeypatch):
|
||
"""Uvicorn 启动不能清除数据库初始化阶段已经发布的停止请求"""
|
||
from app import main
|
||
|
||
stop_event = threading.Event()
|
||
monkeypatch.setattr(main.global_vars, "STOP_EVENT", stop_event)
|
||
main.global_vars.stop_system()
|
||
|
||
async def serve(_self, sockets=None):
|
||
assert main.global_vars.is_system_stopped
|
||
|
||
monkeypatch.setattr(main.uvicorn.Server, "serve", serve)
|
||
server = object.__new__(main.MoviePilotServer)
|
||
asyncio.run(server.serve())
|
||
|
||
|
||
@pytest.mark.parametrize("endpoint_name", ["restart_system", "upgrade_system"])
|
||
@pytest.mark.parametrize(
|
||
"initially_stopped",
|
||
[False, True],
|
||
ids=["running", "stopping"],
|
||
)
|
||
def test_restart_endpoint_failure_preserves_stop_state(
|
||
monkeypatch,
|
||
endpoint_name,
|
||
initially_stopped,
|
||
):
|
||
"""重启或升级失败不能发布或撤销停止请求"""
|
||
from app.api.endpoints import system
|
||
|
||
stop_event = threading.Event()
|
||
if initially_stopped:
|
||
stop_event.set()
|
||
monkeypatch.setattr(system.global_vars, "STOP_EVENT", stop_event)
|
||
monkeypatch.setattr(system.SystemHelper, "can_restart", MagicMock(return_value=True))
|
||
monkeypatch.setattr(
|
||
system.SystemHelper,
|
||
"restart" if endpoint_name == "restart_system" else "upgrade",
|
||
MagicMock(return_value=(False, "restart failed")),
|
||
)
|
||
|
||
if endpoint_name == "restart_system":
|
||
response = system.restart_system(None)
|
||
else:
|
||
response = system.upgrade_system(None, None)
|
||
|
||
assert not response.success
|
||
assert stop_event.is_set() is initially_stopped
|
||
|
||
|
||
def test_command_restart_failure_does_not_publish_stop_request(monkeypatch):
|
||
"""命令重启失败时进程仍在运行,不能提前发布停止请求"""
|
||
from app.chain.system import SystemChain
|
||
from app.runtime.config import global_vars
|
||
|
||
stop_event = threading.Event()
|
||
monkeypatch.setattr(global_vars, "STOP_EVENT", stop_event)
|
||
monkeypatch.setattr(SystemChain, "backup_plugins", MagicMock())
|
||
restart = MagicMock(return_value=(False, "restart failed"))
|
||
monkeypatch.setattr("app.chain.system.SystemHelper.restart", restart)
|
||
|
||
chain = object.__new__(SystemChain)
|
||
chain.restart(channel=None, userid=None)
|
||
|
||
restart.assert_called_once_with()
|
||
assert not stop_event.is_set()
|
||
|
||
|
||
def test_stop_modules_continues_after_internal_owner_failures(monkeypatch):
|
||
"""模块关闭编排中的多个失败不能阻断其余清理"""
|
||
stop_agent = AsyncMock(side_effect=RuntimeError("agent failed"))
|
||
monkeypatch.setattr(modules_initializer, "stop_agent", stop_agent)
|
||
dependencies = _patch_module_shutdown_dependencies(monkeypatch)
|
||
dependencies["module"].side_effect = RuntimeError("module failed")
|
||
|
||
asyncio.run(modules_initializer.stop_modules())
|
||
|
||
stop_agent.assert_awaited_once_with()
|
||
for dependency in dependencies.values():
|
||
_assert_completed_once(dependency)
|
||
|
||
|
||
def _patch_module_shutdown_dependencies(monkeypatch) -> dict:
|
||
"""替换 stop_modules 的资源所有者,避免测试启动真实后台服务"""
|
||
dependencies = {}
|
||
for name, method_name in (
|
||
("ModuleManager", "shutdown"),
|
||
("EventManager", "stop"),
|
||
("DisplayHelper", "stop"),
|
||
("DohHelper", "shutdown"),
|
||
("ThreadHelper", "shutdown"),
|
||
("RedisHelper", "close"),
|
||
):
|
||
instance = MagicMock()
|
||
setattr(instance, method_name, MagicMock())
|
||
monkeypatch.setattr(
|
||
modules_initializer,
|
||
name,
|
||
MagicMock(return_value=instance),
|
||
)
|
||
key = name.removesuffix("Helper").removesuffix("Manager").lower()
|
||
dependencies[key] = getattr(instance, method_name)
|
||
|
||
for name in ("stop_message", "stop_frontend", "clear_temp"):
|
||
dependency = MagicMock()
|
||
monkeypatch.setattr(modules_initializer, name, dependency)
|
||
dependencies[name] = dependency
|
||
|
||
async_redis = MagicMock()
|
||
async_redis.close = AsyncMock()
|
||
monkeypatch.setattr(
|
||
modules_initializer,
|
||
"AsyncRedisHelper",
|
||
MagicMock(return_value=async_redis),
|
||
)
|
||
dependencies["async_redis"] = async_redis.close
|
||
close_database = AsyncMock()
|
||
monkeypatch.setattr(modules_initializer, "close_database", close_database)
|
||
dependencies["close_database"] = close_database
|
||
return dependencies
|
||
|
||
|
||
def test_shared_http_close_waits_for_real_lru_eviction(monkeypatch):
|
||
"""最终 HTTP 关闭必须等待真实 LRU 淘汰任务并消费其异常"""
|
||
|
||
class FakeTransport:
|
||
created = []
|
||
|
||
def __init__(self, **_kwargs):
|
||
self.close_started = asyncio.Event()
|
||
self.release_close = asyncio.Event()
|
||
self.closed = False
|
||
self.fail_on_close = not self.created
|
||
if not self.fail_on_close:
|
||
self.release_close.set()
|
||
self.created.append(self)
|
||
|
||
async def aclose(self):
|
||
self.close_started.set()
|
||
await self.release_close.wait()
|
||
self.closed = True
|
||
if self.fail_on_close:
|
||
raise RuntimeError("eviction close failed")
|
||
|
||
monkeypatch.setattr(http_utils, "_MAX_SHARED_TRANSPORTS_PER_LOOP", 1)
|
||
monkeypatch.setattr(http_utils.httpx, "AsyncHTTPTransport", FakeTransport)
|
||
async def run_test():
|
||
transport_kwargs = {
|
||
"proxy": None,
|
||
"verify": True,
|
||
"http2": False,
|
||
"max_keepalive_connections": 1,
|
||
"max_connections": 1,
|
||
}
|
||
evicted_transport = http_utils._get_shared_async_transport(
|
||
**transport_kwargs,
|
||
keepalive_expiry=1,
|
||
)
|
||
active_transport = http_utils._get_shared_async_transport(
|
||
**transport_kwargs,
|
||
keepalive_expiry=2,
|
||
)
|
||
await asyncio.wait_for(evicted_transport.close_started.wait(), timeout=1)
|
||
|
||
loop = asyncio.get_running_loop()
|
||
with http_utils._shared_async_transports_lock:
|
||
eviction_tasks = [
|
||
task
|
||
for task in http_utils._pending_eviction_tasks
|
||
if task.get_loop() is loop
|
||
]
|
||
assert len(eviction_tasks) == 1
|
||
|
||
close_task = asyncio.create_task(http_utils.aclose_shared_async_transports())
|
||
await asyncio.sleep(0)
|
||
try:
|
||
assert not close_task.done()
|
||
evicted_transport.release_close.set()
|
||
await close_task
|
||
await asyncio.sleep(0)
|
||
assert eviction_tasks[0].done()
|
||
assert isinstance(eviction_tasks[0].exception(), RuntimeError)
|
||
assert evicted_transport.closed
|
||
assert active_transport.closed
|
||
with http_utils._shared_async_transports_lock:
|
||
assert not any(
|
||
task.get_loop() is loop
|
||
for task in http_utils._pending_eviction_tasks
|
||
)
|
||
finally:
|
||
evicted_transport.release_close.set()
|
||
active_transport.release_close.set()
|
||
await asyncio.gather(close_task, return_exceptions=True)
|
||
await http_utils.aclose_shared_async_transports()
|
||
|
||
asyncio.run(run_test())
|
||
|
||
def test_shared_http_close_ignores_eviction_from_other_loop():
|
||
"""当前事件循环关闭不能等待其他循环持有的淘汰任务"""
|
||
ready = threading.Event()
|
||
release = threading.Event()
|
||
failures = []
|
||
state = {}
|
||
|
||
def run_foreign_loop():
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
async def delayed_close():
|
||
while not release.is_set():
|
||
await asyncio.sleep(0.01)
|
||
|
||
task = loop.create_task(delayed_close())
|
||
state["task"] = task
|
||
with http_utils._shared_async_transports_lock:
|
||
http_utils._pending_eviction_tasks.add(task)
|
||
task.add_done_callback(http_utils._discard_pending_eviction_task)
|
||
ready.set()
|
||
try:
|
||
loop.run_until_complete(task)
|
||
loop.run_until_complete(asyncio.sleep(0))
|
||
except BaseException as err:
|
||
failures.append(err)
|
||
finally:
|
||
with http_utils._shared_async_transports_lock:
|
||
http_utils._pending_eviction_tasks.discard(task)
|
||
loop.close()
|
||
|
||
thread = threading.Thread(target=run_foreign_loop)
|
||
thread.start()
|
||
try:
|
||
assert ready.wait(timeout=2)
|
||
asyncio.run(http_utils.aclose_shared_async_transports())
|
||
assert thread.is_alive()
|
||
assert not state["task"].done()
|
||
finally:
|
||
release.set()
|
||
thread.join(timeout=2)
|
||
|
||
assert not thread.is_alive()
|
||
assert not failures
|