Files
MoviePilot/tests/test_lifecycle_shutdown.py
T

1299 lines
43 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
from app.startup.initializers import modules as modules_initializer
from app.adapters.network import http as http_utils
from app.runtime.tasks import get_task_registry
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, "clear_loop", MagicMock())
monkeypatch.setattr(lifecycle.global_vars, "stop_system", MagicMock())
for name in (
"init_routers",
"init_plugins",
"init_scheduler",
"init_monitor",
"replay_pending_transfers",
"init_command",
"init_workflow",
):
monkeypatch.setattr(lifecycle, name, MagicMock())
monkeypatch.setattr(lifecycle, "configure_plugin_services", 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())
database_prepare = MagicMock(
side_effect=lambda app: lifecycle.get_application_health(
app
).mark_database_ready()
)
monkeypatch.setattr(
lifecycle,
"prepare_database_component",
database_prepare,
)
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_plugin_monitor": MagicMock(return_value=True),
"stop_workflow": MagicMock(),
"stop_command": MagicMock(),
"stop_monitor": MagicMock(),
"stop_scheduler": MagicMock(),
"stop_agent": AsyncMock(return_value=True),
"stop_transfer": AsyncMock(return_value=True),
"quiesce_plugins": AsyncMock(return_value=True),
"settle_events": AsyncMock(return_value=True),
"quiesce_plugin_services": AsyncMock(return_value=True),
"drain_events": AsyncMock(return_value=True),
"finalize_plugins": MagicMock(return_value=True),
"stop_modules": AsyncMock(),
"close_http": AsyncMock(),
}
for name in (
"stop_workflow",
"stop_command",
"stop_monitor",
"stop_scheduler",
"stop_plugin_monitor",
"finalize_plugins",
):
monkeypatch.setattr(lifecycle, name, shutdown_steps[name])
monkeypatch.setattr(lifecycle, "stop_agent", shutdown_steps["stop_agent"])
monkeypatch.setattr(
lifecycle,
"stop_transfer_runtime",
shutdown_steps["stop_transfer"],
)
monkeypatch.setattr(
lifecycle,
"quiesce_plugins",
shutdown_steps["quiesce_plugins"],
)
monkeypatch.setattr(
lifecycle,
"settle_events",
shutdown_steps["settle_events"],
)
monkeypatch.setattr(
lifecycle,
"quiesce_plugin_services",
shutdown_steps["quiesce_plugin_services"],
)
monkeypatch.setattr(lifecycle, "drain_events", shutdown_steps["drain_events"])
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_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_normal_mode_starts_full_runtime(monkeypatch):
"""正常模式必须初始化插件及后台服务,并在退出时逐项停止。"""
shutdown_steps = _patch_lifespan(monkeypatch)
async def run_lifespan():
async with lifecycle.lifespan(FastAPI()):
pass
asyncio.run(run_lifespan())
lifecycle.global_vars.clear_loop.assert_called_once_with(
lifecycle.global_vars.set_loop.return_value
)
lifecycle.init_modules.assert_awaited_once_with()
lifecycle.prepare_database_component.assert_called_once()
lifecycle.configure_plugin_services.assert_called_once_with()
for name in (
"init_plugins",
"init_scheduler",
"init_monitor",
"replay_pending_transfers",
"init_command",
"init_workflow",
):
getattr(lifecycle, name).assert_called_once_with()
for step in shutdown_steps.values():
_assert_completed_once(step)
def test_lifespan_validation_failure_does_not_clear_outer_loop_owner(monkeypatch):
"""当前生命周期尚未取得 owner 时,启动失败不得清理外层登记。"""
_patch_lifespan(monkeypatch)
monkeypatch.setattr(
lifecycle,
"validate_process_topology",
MagicMock(side_effect=RuntimeError("invalid topology")),
)
async def run_lifespan():
async with lifecycle.lifespan(FastAPI()):
pass
with pytest.raises(RuntimeError, match="invalid topology"):
asyncio.run(run_lifespan())
lifecycle.global_vars.set_loop.assert_not_called()
lifecycle.global_vars.clear_loop.assert_not_called()
def test_lifespan_settles_plugin_handlers_before_legacy_hooks(monkeypatch) -> None:
"""整理尾事件必须在 handler 停用后结算,并先于旧插件停机 hook。"""
shutdown_steps = _patch_lifespan(monkeypatch)
order: list[str] = []
for name in (
"stop_transfer",
"quiesce_plugins",
"settle_events",
"quiesce_plugin_services",
"drain_events",
"finalize_plugins",
):
shutdown_steps[name].side_effect = (
lambda current=name: order.append(current) or True
)
async def run_lifespan() -> None:
"""运行一个完整的隔离生命周期。"""
async with lifecycle.lifespan(FastAPI()):
pass
asyncio.run(run_lifespan())
assert order == [
"stop_transfer",
"quiesce_plugins",
"settle_events",
"quiesce_plugin_services",
"drain_events",
"finalize_plugins",
]
_ORDERED_SHUTDOWN_STEPS = (
"stop_plugin_monitor",
"backup_plugins",
"stop_workflow",
"stop_command",
"stop_monitor",
"stop_scheduler",
"stop_agent",
"stop_transfer",
"quiesce_plugins",
"settle_events",
"quiesce_plugin_services",
"drain_events",
"finalize_plugins",
"stop_modules",
"close_http",
)
@pytest.mark.parametrize(
"failing_step",
(
"stop_plugin_monitor",
"stop_monitor",
"stop_scheduler",
"stop_agent",
"stop_transfer",
"quiesce_plugins",
"settle_events",
"quiesce_plugin_services",
"drain_events",
"finalize_plugins",
),
)
def test_lifespan_stops_releasing_dependencies_when_owner_does_not_converge(
monkeypatch,
failing_step,
):
"""关键 owner 未收敛时不得关闭仍被活任务使用的后续依赖。"""
shutdown_steps = _patch_lifespan(monkeypatch)
shutdown_steps[failing_step].return_value = False
failed_index = _ORDERED_SHUTDOWN_STEPS.index(failing_step)
completed_steps = _ORDERED_SHUTDOWN_STEPS[: failed_index + 1]
blocked_steps = _ORDERED_SHUTDOWN_STEPS[failed_index + 1 :]
async def run_lifespan():
"""启动并关闭隔离后的应用生命周期。"""
async with lifecycle.lifespan(FastAPI()):
pass
asyncio.run(run_lifespan())
for name in (*completed_steps, "logger"):
_assert_completed_once(shutdown_steps[name])
for name in blocked_steps:
shutdown_steps[name].assert_not_called()
def test_task_registry_nonconvergence_blocks_all_dependency_release(monkeypatch):
"""最前置任务 owner 超时后不得继续释放插件、模块或 HTTP 依赖。"""
shutdown_steps = _patch_lifespan(monkeypatch)
shutdown = AsyncMock(return_value=False)
monkeypatch.setattr(lifecycle.TaskRegistry, "shutdown", shutdown)
app = FastAPI()
async def run_lifespan() -> None:
"""运行后台登记器无法收敛的隔离生命周期。"""
async with lifecycle.lifespan(app):
pass
asyncio.run(run_lifespan())
shutdown.assert_awaited_once_with(timeout_seconds=30.0)
for name, step in shutdown_steps.items():
if name == "logger":
_assert_completed_once(step)
else:
step.assert_not_called()
assert isinstance(app.state.task_registry, lifecycle.TaskRegistry)
def test_closed_task_registry_rejects_late_shutdown_tasks(monkeypatch) -> None:
"""首屏障完成后,后续 stop hook 的晚到任务不得落回默认登记器。"""
_patch_lifespan(monkeypatch)
app = FastAPI()
async def run_lifespan() -> None:
"""结束完整 lifespan 后验证当前发布的仍是已封口登记器。"""
async with lifecycle.lifespan(app):
pass
registry = get_task_registry()
assert registry is app.state.task_registry
with pytest.raises(RuntimeError, match="正在关闭"):
registry.create(asyncio.sleep(0), owner="shutdown.late_task")
asyncio.run(run_lifespan())
def test_plugin_settlement_cannot_bypass_task_registry_shutdown_budget(
monkeypatch,
) -> None:
"""未收敛 settlement 必须交给首屏障判定,lifespan 不得提前无界等待。"""
shutdown_steps = _patch_lifespan(monkeypatch)
shutdown = AsyncMock(return_value=False)
monkeypatch.setattr(lifecycle.TaskRegistry, "shutdown", shutdown)
started = asyncio.Event()
release = asyncio.Event()
async def settle_plugins() -> None:
"""模拟停机时仍未结束的插件同步任务。"""
started.set()
await release.wait()
lifecycle.init_extra.side_effect = settle_plugins
async def run_lifespan() -> None:
"""确认 context 能由 TaskRegistry 的失败结果立即结束。"""
async with lifecycle.lifespan(FastAPI()):
await started.wait()
release.set()
await asyncio.sleep(0)
asyncio.run(asyncio.wait_for(run_lifespan(), timeout=0.5))
shutdown.assert_awaited_once_with(timeout_seconds=30.0)
for name, step in shutdown_steps.items():
if name == "logger":
_assert_completed_once(step)
else:
step.assert_not_called()
def test_lifespan_waits_for_uncancellable_plugin_settlement_before_shutdown(
monkeypatch,
):
"""已进入同步 I/O 的 settlement 必须真实结束,才能备份和释放资源。"""
shutdown_steps = _patch_lifespan(monkeypatch)
order = []
shutdown_steps["backup_plugins"].side_effect = lambda: order.append("backup")
async def run_lifespan():
started = asyncio.Event()
release = asyncio.Event()
async def settle_plugins():
started.set()
try:
await release.wait()
except asyncio.CancelledError:
# 模拟 run_in_threadpool_to_completion:外层取消只能封住新工作,
# 已进入同步插件源码/依赖修改的调用仍持有 owner 到真实终态。
await release.wait()
order.append("settled")
lifecycle.init_extra.side_effect = settle_plugins
async with lifecycle.lifespan(FastAPI()):
await started.wait()
asyncio.get_running_loop().call_later(0.02, release.set)
asyncio.run(run_lifespan())
assert order[:2] == ["settled", "backup"]
def test_lifespan_configures_plugin_services_before_restore(monkeypatch):
"""插件恢复依赖的外部系统服务必须先于恢复阶段完成装配。"""
shutdown_steps = _patch_lifespan(monkeypatch)
order = []
lifecycle.configure_plugin_services.side_effect = lambda: order.append("configure")
lifecycle.SystemChain.return_value.restore_plugins.side_effect = (
lambda: order.append("restore")
)
async def run_lifespan():
async with lifecycle.lifespan(FastAPI()):
pass
asyncio.run(run_lifespan())
assert order == ["configure", "restore"]
_assert_completed_once(shutdown_steps["close_http"])
def test_lifespan_safe_mode_skips_optional_runtime(monkeypatch):
"""安全模式只启动基础模块,并跳过插件及可选后台服务。"""
shutdown_steps = _patch_lifespan(monkeypatch)
monkeypatch.setattr(lifecycle.settings, "MOVIEPILOT_SAFE_MODE", True)
async def run_lifespan():
async with lifecycle.lifespan(FastAPI()):
pass
asyncio.run(run_lifespan())
lifecycle.init_modules.assert_awaited_once_with()
lifecycle.prepare_database_component.assert_called_once()
for name in (
"init_plugins",
"init_scheduler",
"init_monitor",
"replay_pending_transfers",
"init_command",
"init_workflow",
):
getattr(lifecycle, name).assert_not_called()
for name in (
"backup_plugins",
"stop_workflow",
"stop_command",
"stop_monitor",
"stop_scheduler",
"stop_plugin_monitor",
"quiesce_plugins",
"settle_events",
"quiesce_plugin_services",
"finalize_plugins",
):
shutdown_steps[name].assert_not_called()
_assert_completed_once(shutdown_steps["stop_modules"])
_assert_completed_once(shutdown_steps["stop_agent"])
_assert_completed_once(shutdown_steps["stop_transfer"])
_assert_completed_once(shutdown_steps["drain_events"])
_assert_completed_once(shutdown_steps["close_http"])
_assert_completed_once(shutdown_steps["logger"])
@pytest.mark.asyncio
async def test_event_drain_does_not_materialize_manager(monkeypatch) -> None:
"""模块尚未创建事件总线时,停机屏障应直接收敛而不反向构造。"""
event_manager_type = MagicMock()
event_manager_type.get_existing_instance.return_value = None
monkeypatch.setattr(modules_initializer, "EventManager", event_manager_type)
assert await modules_initializer.drain_events() is True
event_manager_type.get_existing_instance.assert_called_once_with()
event_manager_type.assert_not_called()
@pytest.mark.asyncio
async def test_event_settlement_keeps_tail_event_admission_open(monkeypatch) -> None:
"""中间结算只等待在途 handler,不得提前封死旧 hook 的尾事件。"""
event_manager = MagicMock()
event_manager.drain_async = AsyncMock(return_value=True)
event_manager_type = MagicMock()
event_manager_type.get_existing_instance.return_value = event_manager
monkeypatch.setattr(modules_initializer, "EventManager", event_manager_type)
assert await modules_initializer.settle_events() is True
event_manager.drain_async.assert_awaited_once_with(seal=False)
def test_lifecycle_manifest_declares_normal_and_safe_mode_order() -> None:
"""组件清单应显式冻结依赖、模式、启动/关闭顺序和超时预算。"""
app = FastAPI()
normal = lifecycle.get_lifecycle_manifest(app, safe_mode=False)
safe = lifecycle.get_lifecycle_manifest(app, safe_mode=True)
normal_start = [
item["name"]
for item in sorted(
(entry for entry in normal if entry["start_order"] is not None),
key=lambda entry: entry["start_order"],
)
]
normal_stop = [
item["name"]
for item in sorted(
(entry for entry in normal if entry["stop_order"] is not None),
key=lambda entry: entry["stop_order"],
)
]
safe_names = {item["name"] for item in safe}
assert normal_start == [
"后台任务登记器",
"数据库准备",
"HTTP 基础能力",
"领域依赖装配",
"数据库引擎预热",
"数据库连接预算",
"路由",
"模块服务",
"插件备份恢复",
"插件",
"定时器",
"监控器",
"待处理整理回放",
"命令服务",
"工作流",
]
assert normal_stop == [
"后台任务登记器",
"插件变更监控",
"插件备份",
"工作流",
"命令服务",
"监控器",
"定时器",
"AI智能体会话",
"整理后台服务",
"插件事件入口",
"事件尾任务结算",
"插件后台服务",
"事件投递屏障",
"插件",
"模块服务",
"HTTP 基础能力",
]
assert safe_names == {
"后台任务登记器",
"数据库准备",
"HTTP 基础能力",
"领域依赖装配",
"数据库引擎预热",
"数据库连接预算",
"路由",
"模块服务",
"AI智能体会话",
"整理后台服务",
"事件投递屏障",
}
assert all(item["start_failure"] == "fail_fast" for item in normal)
assert {
item["name"]
for item in normal
if item["stop_failure"] == "fail_fast"
} == {
"插件变更监控",
"后台任务登记器",
"监控器",
"定时器",
"AI智能体会话",
"整理后台服务",
"插件事件入口",
"事件尾任务结算",
"插件后台服务",
"事件投递屏障",
"插件",
}
assert all(
item["stop_failure"] == "continue"
for item in normal
if item["name"]
not in {
"插件变更监控",
"后台任务登记器",
"监控器",
"定时器",
"AI智能体会话",
"整理后台服务",
"插件事件入口",
"事件尾任务结算",
"插件后台服务",
"事件投递屏障",
"插件",
}
)
assert all(
item["start_timeout_seconds"] or item["stop_timeout_seconds"]
for item in normal
)
def test_startup_step_records_duration_without_changing_result(monkeypatch):
"""启动阶段计时必须保留返回值,并输出稳定的阶段名称和毫秒耗时。"""
perf_counter = MagicMock(side_effect=[10.0, 10.125])
logger_info = MagicMock()
monkeypatch.setattr(lifecycle.time, "perf_counter", perf_counter)
monkeypatch.setattr(lifecycle.logger, "info", logger_info)
result = asyncio.run(
lifecycle.run_startup_step("契约测试", lambda: "ready")
)
assert result == "ready"
logger_info.assert_called_once_with(
"启动%s完成,耗时=%.2fms",
"契约测试",
125.0,
)
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):
"""启动期也必须把同步引擎建出来一次,把首次创建钉在单线程期
数据库准备已统一进入 lifespan,所有受支持 ASGI 入口都会先由 init_db() 创建同步引擎;
随后的显式预热仍用于冻结顺序契约,确保同步/异步引擎都早于 Router、Module 和后台线程。
"""
_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, _api_prefix: 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())
lifecycle.global_vars.clear_loop.assert_called_once_with(
lifecycle.global_vars.set_loop.return_value
)
# 失败要发生在任何东西被初始化之前,否则模块起来了却没人关:关停块在 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, "run_api_server", lambda: calls.append("server"))
main.run_application()
assert stop_event.is_set()
assert calls == [
"signal",
"signal",
"tray",
"server",
]
def test_asgi_and_main_entrypoints_share_the_same_app_instance():
"""ASGI 工厂入口与主程序入口必须暴露同一个 FastAPI 实例。"""
from app import factory, main
assert main.app is factory.app
def test_lifespan_does_not_yield_after_migration_failure(monkeypatch):
"""数据库迁移失败时 lifespan 必须 fail-fast 且不得发布 ready。"""
migration_error = RuntimeError("migration failed")
_patch_lifespan(monkeypatch)
monkeypatch.setattr(
lifecycle,
"prepare_database_component",
MagicMock(side_effect=migration_error),
)
app = FastAPI()
async def run_lifespan():
async with lifecycle.lifespan(app):
pytest.fail("数据库迁移失败后不应进入服务阶段")
with pytest.raises(RuntimeError) as raised:
asyncio.run(run_lifespan())
assert raised.value is migration_error
assert app.state.moviepilot_health.is_ready is False
assert app.state.moviepilot_health.phase.value == "failed"
def test_lifespan_cleans_started_owners_after_late_startup_failure(monkeypatch):
"""后段启动失败时应按同一停机策略回收已启动及部分启动的 owner。"""
shutdown_steps = _patch_lifespan(monkeypatch)
startup_error = RuntimeError("command startup failed")
lifecycle.init_command.side_effect = startup_error
app = FastAPI()
async def run_lifespan() -> None:
"""运行一个在命令服务阶段失败的隔离生命周期。"""
async with lifecycle.lifespan(app):
pytest.fail("命令服务启动失败后不应发布运行态")
with pytest.raises(RuntimeError) as raised:
asyncio.run(run_lifespan())
assert raised.value is startup_error
lifecycle.global_vars.stop_system.assert_not_called()
for name in (
"stop_plugin_monitor",
"backup_plugins",
"stop_command",
"stop_monitor",
"stop_scheduler",
"stop_agent",
"stop_transfer",
"quiesce_plugins",
"settle_events",
"quiesce_plugin_services",
"drain_events",
"finalize_plugins",
"stop_modules",
"close_http",
):
_assert_completed_once(shutdown_steps[name])
shutdown_steps["stop_workflow"].assert_not_called()
shutdown_steps["logger"].assert_not_called()
assert isinstance(app.state.task_registry, lifecycle.TaskRegistry)
assert get_task_registry() is app.state.task_registry
assert app.state.moviepilot_health.phase.value == "failed"
def test_startup_failure_cleanup_honors_transfer_fail_fast(monkeypatch):
"""启动失败清理中整理 owner 未收敛时也不得继续释放插件和模块。"""
shutdown_steps = _patch_lifespan(monkeypatch)
lifecycle.init_command.side_effect = RuntimeError("command startup failed")
shutdown_steps["stop_transfer"].return_value = False
async def run_lifespan() -> None:
"""运行后段失败且整理线程无法收敛的隔离生命周期。"""
async with lifecycle.lifespan(FastAPI()):
pytest.fail("命令服务启动失败后不应发布运行态")
with pytest.raises(RuntimeError, match="command startup failed"):
asyncio.run(run_lifespan())
for name in (
"stop_plugin_monitor",
"backup_plugins",
"stop_command",
"stop_monitor",
"stop_scheduler",
"stop_agent",
"stop_transfer",
):
_assert_completed_once(shutdown_steps[name])
for name in (
"quiesce_plugins",
"settle_events",
"quiesce_plugin_services",
"drain_events",
"finalize_plugins",
"stop_modules",
"close_http",
):
shutdown_steps[name].assert_not_called()
shutdown_steps["stop_workflow"].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_failure(monkeypatch):
"""模块关闭编排中的单个失败不能阻断其余清理。"""
dependencies = _patch_module_shutdown_dependencies(monkeypatch)
dependencies["module"].side_effect = RuntimeError("module failed")
asyncio.run(modules_initializer.stop_modules())
for dependency in dependencies.values():
_assert_completed_once(dependency)
def test_stop_modules_drains_web_agent_tasks_before_persistence(monkeypatch):
"""关闭时先收口 Web Agent,再关闭持久化准入和数据库任务。"""
order = []
dependencies = _patch_module_shutdown_dependencies(monkeypatch)
monkeypatch.setattr(
modules_initializer,
"shutdown_web_agent_background_tasks",
AsyncMock(side_effect=lambda: order.append("web-agent")),
)
persistence = MagicMock()
persistence.begin_shutdown = MagicMock(
side_effect=lambda: order.append("persistence-admission")
)
persistence.shutdown = AsyncMock(side_effect=lambda: order.append("persistence"))
monkeypatch.setattr(
modules_initializer,
"get_configured_agent_chat_persistence",
MagicMock(return_value=persistence),
)
monkeypatch.setattr(
modules_initializer,
"stop_database_worker",
AsyncMock(side_effect=lambda: order.append("database")),
)
monkeypatch.setattr(modules_initializer, "_database_worker", object())
asyncio.run(modules_initializer.stop_modules())
assert order == ["web-agent", "persistence-admission", "persistence", "database"]
@pytest.mark.asyncio
async def test_shutdown_timeout_does_not_skip_database_worker_cleanup(monkeypatch):
"""模块关闭超时取消当前步骤后仍应继续收口数据库 worker。"""
started = asyncio.Event()
async def blocked_web_agent_shutdown():
started.set()
await asyncio.Event().wait()
_patch_module_shutdown_dependencies(monkeypatch)
monkeypatch.setattr(
modules_initializer,
"shutdown_web_agent_background_tasks",
blocked_web_agent_shutdown,
)
monkeypatch.setattr(
modules_initializer,
"wait_web_agent_background_tasks",
AsyncMock(),
)
persistence = MagicMock()
persistence.begin_shutdown = MagicMock()
persistence.shutdown = AsyncMock()
monkeypatch.setattr(
modules_initializer,
"get_configured_agent_chat_persistence",
MagicMock(return_value=persistence),
)
stop_database_worker = AsyncMock()
monkeypatch.setattr(modules_initializer, "stop_database_worker", stop_database_worker)
monkeypatch.setattr(modules_initializer, "_database_worker", object())
shutdown = asyncio.create_task(
lifecycle.run_shutdown_step(
"模块服务",
modules_initializer.stop_modules,
timeout_seconds=0.01,
)
)
await started.wait()
completed = await shutdown
assert completed is False
stop_database_worker.assert_awaited_once_with()
@pytest.mark.asyncio
async def test_shutdown_timeout_has_hard_bound_for_nonconverging_cleanup() -> None:
"""关闭收尾不响应取消时,生命周期调用仍必须在预算内返回。"""
started = asyncio.Event()
cancel_requested = asyncio.Event()
release = asyncio.Event()
settled = asyncio.Event()
async def nonconverging_shutdown() -> None:
started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
cancel_requested.set()
await release.wait()
settled.set()
raise
started_at = asyncio.get_running_loop().time()
shutdown = asyncio.create_task(
lifecycle.run_shutdown_step(
"不可收敛阶段",
nonconverging_shutdown,
timeout_seconds=0.01,
)
)
await started.wait()
completed = await shutdown
elapsed = asyncio.get_running_loop().time() - started_at
assert completed is False
assert elapsed < 0.2
await asyncio.wait_for(cancel_requested.wait(), timeout=0.2)
assert not settled.is_set()
release.set()
await asyncio.wait_for(settled.wait(), timeout=0.2)
@pytest.mark.asyncio
async def test_shutdown_step_reports_explicit_nonconvergence() -> None:
"""同步和异步 owner 显式返回 False 时都必须向生命周期传播失败。"""
async def async_nonconverging_shutdown() -> bool:
"""模拟已经完成等待但仍持有资源的异步关闭入口。"""
return False
assert await lifecycle.run_shutdown_step(
"同步 owner",
lambda: False,
) is False
assert await lifecycle.run_shutdown_step(
"异步 owner",
async_nonconverging_shutdown,
) is False
assert await lifecycle.run_shutdown_step(
"已收敛 owner",
lambda: None,
) is True
def _patch_module_shutdown_dependencies(monkeypatch) -> dict:
"""替换 stop_modules 的资源所有者,避免测试启动真实后台服务"""
dependencies = {}
for name, method_name in (
("ModuleManager", "shutdown"),
("EventManager", "stop_async"),
("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 (
"close_browser_sessions",
"stop_message",
"stop_frontend",
"clear_temp",
):
dependency = MagicMock()
monkeypatch.setattr(modules_initializer, name, dependency)
dependencies[name] = dependency
close_image_proxy_block_log_coalescer = AsyncMock()
monkeypatch.setattr(
modules_initializer,
"close_image_proxy_block_log_coalescer",
close_image_proxy_block_log_coalescer,
)
dependencies["close_image_proxy_block_log_coalescer"] = (
close_image_proxy_block_log_coalescer
)
stop_managed_resources = AsyncMock()
monkeypatch.setattr(
modules_initializer,
"stop_managed_resources",
stop_managed_resources,
)
dependencies["stop_managed_resources"] = stop_managed_resources
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_browser_sessions_close_before_managed_resources(monkeypatch) -> None:
"""显示等宿主资源必须晚于浏览器会话释放,避免存活上下文失去依赖。"""
calls: list[str] = []
dependencies = _patch_module_shutdown_dependencies(monkeypatch)
dependencies["close_browser_sessions"].side_effect = lambda: calls.append("browser")
async def stop_resources() -> None:
calls.append("resources")
dependencies["stop_managed_resources"].side_effect = stop_resources
asyncio.run(modules_initializer.stop_modules())
assert calls == ["browser", "resources"]
def test_module_shutdown_waits_for_image_proxy_log_coalescer(monkeypatch) -> None:
"""模块关闭必须等待图片安全日志的在途聚合任务收口。"""
dependencies = _patch_module_shutdown_dependencies(monkeypatch)
asyncio.run(modules_initializer.stop_modules())
dependencies["close_image_proxy_block_log_coalescer"].assert_awaited_once_with()
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.httpx2, "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