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