diff --git a/app/runtime/extensions/module_manager.py b/app/runtime/extensions/module_manager.py index 4b852f525..d06bbc3c6 100644 --- a/app/runtime/extensions/module_manager.py +++ b/app/runtime/extensions/module_manager.py @@ -229,6 +229,11 @@ class ModuleManager(metaclass=Singleton): converged = self._runtime.shutdown(reason="application_shutdown") self._refresh_running_projection() if converged: + eventmanager.remove_event_listener( + EventType.ConfigChanged, + self.handle_config_changed, + ) + eventmanager.unregister_handler_instance_resolver("modules") logger.info("模块运行时关闭完成") else: logger.error("模块运行时关闭后仍有资源 owner 未收敛") diff --git a/app/startup/composition/database.py b/app/startup/composition/database.py index e19807eec..aa8483a3e 100644 --- a/app/startup/composition/database.py +++ b/app/startup/composition/database.py @@ -14,6 +14,7 @@ from app.application.database import ( DatabaseGovernance, DatabaseHealthService, configure_database_governance, + reset_database_governance, ) from app.application.maintenance import ( DataCleanupService, @@ -165,6 +166,7 @@ def publish_database_services(composition: DatabaseComposition) -> None: def reset_database_services() -> None: """撤销当前 lifespan 发布的全部数据库派生服务。""" + reset_database_governance() reset_transaction_runners() reset_workflow_query() reset_plugin_persistence() diff --git a/app/startup/initializers/modules.py b/app/startup/initializers/modules.py index 7dccc97d4..bf6c6e968 100644 --- a/app/startup/initializers/modules.py +++ b/app/startup/initializers/modules.py @@ -1,8 +1,7 @@ import asyncio import inspect import sys -from collections.abc import Awaitable -from typing import Callable, cast +from typing import Callable from app.adapters.cache.redis import AsyncRedisHelper, RedisHelper @@ -21,11 +20,13 @@ from app.adapters.system.host import SystemUtils from app.adapters.system.resource import ( ResourceHelper, configure_resource_version_provider, + reset_resource_version_provider, ) from app.application.configuration import ( TransferRetryConfig, configure_transfer_retry_config, get_configured_system_config, + reset_transfer_retry_config, ) from app.application.messaging.agent import ( dispatch_web_agent_message_event, @@ -39,11 +40,11 @@ from app.application.messaging.message import ( MessageHelper, stop_message, ) -from app.application.module import configure_module_runtime +from app.application.module import configure_module_runtime, reset_module_runtime from app.application.outbox import configure_outbox_dispatcher from app.application.security.url import close_image_proxy_block_log_coalescer -from app.application.service import configure_service_directory -from app.application.workflow import configure_workflow_execution +from app.application.service import configure_service_directory, reset_service_directory +from app.application.workflow import configure_workflow_execution, reset_workflow_execution from app.command import CommandChain from app.db.adapters.workflow import ( TransactionalWorkflowExecutionService, @@ -59,6 +60,7 @@ from app.runtime.extensions.module_manager import ModuleManager from app.runtime.extensions.service_config import ( ServiceConfigHelper, configure_service_config_reader, + reset_service_config_reader, ) from app.runtime.log import logger from app.runtime.settings import ( @@ -72,10 +74,12 @@ from app.schemas.types import EventType, SystemConfigKey from app.startup.composition.agent import ( compose_agent, publish_agent_services, + reset_agent_services, ) from app.startup.composition.chain import ( configure_chain_runtime_context, configure_wallpaper_services, + reset_chain_services, ) from app.startup.composition.configuration import ( compose_configuration, @@ -92,25 +96,39 @@ from app.startup.composition.database import ( start_database_runtime, stop_database_runtime, ) -from app.startup.composition.network import configure_application_network_ports -from app.startup.composition.outbox import build_outbox_dispatcher +from app.startup.composition.network import ( + configure_application_network_ports, + reset_application_network_ports, +) +from app.startup.composition.outbox import build_outbox_dispatcher, reset_outbox_services from app.startup.composition.runtime import ( RuntimeInputs, compose_runtime, compose_runtime_dependencies, publish_runtime, + reset_runtime, ) from app.startup.composition.security import ( configure_security_access, configure_security_services, + reset_security_access, + reset_security_services, +) +from app.startup.composition.server import configure_server_services, reset_server_services +from app.startup.initializers.agent import ( + configure_agent_data_context, + init_agent, + reset_agent_data_context, ) -from app.startup.composition.server import configure_server_services -from app.startup.initializers.agent import configure_agent_data_context, init_agent from app.startup.initializers.resources import ( init_managed_resources, + reset_managed_resources, stop_managed_resources, ) -from app.startup.initializers.scheduler import configure_scheduler_agent_tasks +from app.startup.initializers.scheduler import ( + configure_scheduler_agent_tasks, + reset_scheduler_bindings, +) def configure_runtime_data_providers() -> None: @@ -123,6 +141,88 @@ def configure_runtime_data_providers() -> None: ) +def reset_runtime_data_providers() -> None: + """按发布逆序撤销模块、服务目录和配置读取 Provider。""" + reset_service_directory() + reset_module_runtime() + reset_service_config_reader() + + +def reset_event_services() -> None: + """撤销启动组合根登记的事件通知、监听器和宿主 resolver。""" + event_manager = EventManager.get_existing_instance() + if event_manager is None: + return + event_manager.remove_event_listener( + EventType.NoticeMessage, + dispatch_web_agent_message_event, + ) + event_manager.unregister_handler_instance_resolver("host") + event_manager.reset_error_notifier() + + +def reset_tool_services() -> None: + """撤销工具管理器持有的 Agent 数据上下文与目录快照。""" + from app.agent.tools.manager import moviepilot_tool_manager + + moviepilot_tool_manager.reset_data_context() + + +def _module_provider_reset_steps() -> tuple[tuple[str, Callable[[], object]], ...]: + """返回当前模块 lifespan 的完整 Provider 逆序撤销清单。""" + return ( + ("事件服务", reset_event_services), + ("资源版本 Provider", reset_resource_version_provider), + ("认证访问服务", reset_security_access), + ("Chain 服务", reset_chain_services), + ("托管资源引用", reset_managed_resources), + ("调度器绑定", reset_scheduler_bindings), + ("Agent 工具服务", reset_tool_services), + ( + "Agent 服务", + lambda: reset_agent_services( + data_context_resetter=reset_agent_data_context, + ), + ), + ("传输重试配置", reset_transfer_retry_config), + ("Outbox 服务", reset_outbox_services), + ("工作流执行服务", reset_workflow_execution), + ("中心服务", reset_server_services), + ("运行时数据 Provider", reset_runtime_data_providers), + ("HostRuntime 投影", reset_runtime), + ("数据库服务", reset_database_services), + ("认证服务", reset_security_services), + ("应用网络端口", reset_application_network_ports), + ("配置服务", reset_configuration), + ) + + +def reset_module_providers() -> bool: + """逐项撤销模块 Provider;单项失败不跳过后续 owner,并返回整体结果。""" + converged = True + for name, callback in _module_provider_reset_steps(): + try: + callback() + except Exception as error: + logger.error("撤销%s失败:%s", name, error) + converged = False + return converged + + +def _existing_singleton(instance_type: type) -> object | None: + """读取已经物化的 Singleton,启动失败回滚时禁止反向创建新 owner。""" + getter = getattr(instance_type, "get_existing_instance", None) + return getter() if callable(getter) else None + + +def _call_existing_singleton(instance_type: type, method_name: str) -> object: + """调用已存在 Singleton 的关闭方法;owner 未创建时视为已经收敛。""" + instance = _existing_singleton(instance_type) + if instance is None: + return True + return getattr(instance, method_name)() + + def notify_event_error(title: str, message: str) -> None: """将事件总线错误转发到系统消息通道。""" MessageHelper().put( @@ -345,14 +445,36 @@ async def stop_modules() -> bool: return converged await run_step("图片代理安全日志合并器", close_image_proxy_block_log_coalescer) - await run_step("模块", lambda: ModuleManager().shutdown(), offload=True) - await run_step("事件消费", lambda: EventManager().stop_async()) + await run_step( + "模块", + lambda: _call_existing_singleton(ModuleManager, "shutdown"), + offload=True, + ) + await run_step( + "事件消费", + lambda: _call_existing_singleton(EventManager, "stop_async"), + ) await run_step("浏览器会话", close_browser_sessions, offload=True) await run_step("托管资源", stop_managed_resources) - await run_step("DoH服务", lambda: DohHelper().shutdown(), offload=True) - await run_step("线程池", lambda: ThreadHelper().shutdown(), offload=True) - await run_step("Redis缓存连接", lambda: RedisHelper().close(), offload=True) - await run_step("异步Redis缓存连接", lambda: AsyncRedisHelper().close()) + await run_step( + "DoH服务", + lambda: _call_existing_singleton(DohHelper, "shutdown"), + offload=True, + ) + await run_step( + "线程池", + lambda: _call_existing_singleton(ThreadHelper, "shutdown"), + offload=True, + ) + await run_step( + "Redis缓存连接", + lambda: _call_existing_singleton(RedisHelper, "close"), + offload=True, + ) + await run_step( + "异步Redis缓存连接", + lambda: _call_existing_singleton(AsyncRedisHelper, "close"), + ) # Web Agent 的取消 finally 可能还要写入最终展示快照,必须先完成任务收尾,再关闭写入准入。 web_agent_drained = await run_step( "Web Agent后台任务", @@ -362,29 +484,38 @@ async def stop_modules() -> bool: if not web_agent_drained: web_agent_drained = await run_step("Web Agent后台任务收尾", wait_web_agent_background_tasks) if web_agent_drained: - await run_step( - "Agent会话持久化准入", - lambda: get_configured_agent_chat_persistence().begin_shutdown(), - ) - persistence_drained = await run_step( - "Agent会话持久化", - lambda: get_configured_agent_chat_persistence().shutdown(), - ) + try: + persistence = get_configured_agent_chat_persistence() + except RuntimeError: + persistence = None + if persistence is None: + persistence_drained = True + else: + await run_step( + "Agent会话持久化准入", + persistence.begin_shutdown, + ) + persistence_drained = await run_step( + "Agent会话持久化", + persistence.shutdown, + ) else: persistence_drained = False all_converged = False logger.error("Web Agent任务未完成收尾,跳过持久化和数据库关闭以保护活动事务") if persistence_drained: await run_step("数据库任务", stop_database_runtime) + await run_step("前端服务", stop_frontend, offload=True) + await run_step("临时文件", clear_temp, offload=True) + if persistence_drained: if not database_runtime_active(): - await run_step("数据库服务", reset_database_services) - await run_step("配置服务", reset_configuration) + await run_step("模块 Provider", reset_module_providers) await run_step("数据库连接", close_database) else: all_converged = False - logger.error("数据库任务未收敛,跳过数据库连接关闭以避免运行中事务使用已释放连接") - await run_step("前端服务", stop_frontend, offload=True) - await run_step("临时文件", clear_temp, offload=True) + logger.error( + "数据库任务未收敛,保留全部 Provider 和数据库连接以供诊断与重试" + ) return all_converged @@ -509,14 +640,8 @@ async def init_modules() -> HostRuntime: except Exception as cleanup_error: # noqa: BLE001 保留原始启动异常 logger.error(f"模块启动失败后的消息资源清理失败:{cleanup_error}") try: - await stop_database_runtime() + if await stop_modules() is False: + logger.error("模块启动失败后的资源 owner 未完全收敛") except Exception as cleanup_error: # noqa: BLE001 保留原始启动异常 - logger.error(f"模块启动失败后的数据库任务清理失败:{cleanup_error}") - if not database_runtime_active(): - reset_database_services() - reset_configuration() - try: - await cast(Callable[[], Awaitable[None]], close_database)() - except Exception as cleanup_error: # noqa: BLE001 保留原始启动异常 - logger.error(f"模块启动失败后的数据库连接清理失败:{cleanup_error}") + logger.error(f"模块启动失败后的统一资源清理失败:{cleanup_error}") raise diff --git a/app/startup/initializers/scheduler.py b/app/startup/initializers/scheduler.py index 9f307f9d4..faa0ab01a 100644 --- a/app/startup/initializers/scheduler.py +++ b/app/startup/initializers/scheduler.py @@ -31,6 +31,15 @@ def reset_scheduler_runtime() -> None: reset_scheduler_class() +def reset_scheduler_bindings() -> None: + """在 Scheduler 已停止后撤销当前 lifespan 的仓储、业务能力和类登记。""" + # Singleton 元类保留旧动态接口,当前 owner 在此精确收窄为 Scheduler。 + scheduler = Scheduler.get_existing_instance() # type: ignore[no-untyped-call] + if scheduler is not None: + scheduler.reset_runtime_bindings() + reset_scheduler_runtime() + + def configure_scheduler_agent_tasks(repository: AgentTaskRepository) -> None: """在调度器启动前注入自主任务仓储。""" configure_scheduler_runtime() @@ -91,18 +100,14 @@ def stop_scheduler() -> Optional[Awaitable[None]]: try: asyncio.get_running_loop() except RuntimeError: - try: - scheduler.stop() - finally: - reset_scheduler_runtime() + scheduler.stop() + reset_scheduler_bindings() return None async def stop_and_reset() -> None: """等待异步调度器收口后清除 provider。""" - try: - await scheduler.stop_async() - finally: - reset_scheduler_runtime() + await scheduler.stop_async() + reset_scheduler_bindings() return stop_and_reset() diff --git a/docs/architecture-optimization-checklist.md b/docs/architecture-optimization-checklist.md index 279115196..6fd148fb4 100644 --- a/docs/architecture-optimization-checklist.md +++ b/docs/architecture-optimization-checklist.md @@ -78,7 +78,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` | Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement | | Python 源码量 | 约 298,700 行 | 60 个文件超过 1,000 行,10 个超过 2,000 行 | | 长方法 | 299 个超过 80 行 | 70 个超过 150 行,23 个超过 250 行;大量是私有方法 | -| 全量 mypy 历史债务 | 10,320 / 592 文件 | Runtime 唯一组合 owner 零新增类型债,`modules.py` 低水位下降 4;strict frontier 当前覆盖 41 个文件 | +| 全量 mypy 历史债务 | 10,317 / 592 文件 | 生命周期统一撤销接线继续清除 `modules.py` 3 项调用类型债;strict frontier 当前覆盖 41 个文件 | | Ruff 历史诊断 | 644 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | 覆盖率低水位 | Application 81.00%,Domain 80.67% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | @@ -678,7 +678,8 @@ Pylint 10/10、Ruff、mypy/复杂度 ratchet、宿主与最新官方插件基线 - [x] L4.3 已将 HostRuntime、命名领域 Runtime 和旧 `ApiDataPorts` 投影统一移到单词型 `startup/composition/runtime.py`;`RuntimeDependencies` 由 Agent、Chain、HostRuntime 复用, `HostRuntime.tasks` 显式绑定 lifecycle 的 TaskRegistry,Chain 不再二次构造 transfer execution。 -- [ ] 保留生命周期 manifest 和顺序快照;高扇出在组合根是允许的,但业务实现不能继续沉积其中。 +- [x] 生命周期按显式逆序 reset manifest 撤销全部 Provider;数据库 worker 未收敛时保留 + HostRuntime、Provider 与连接供诊断重试,启动失败复用同一正式清理路径且不反向物化 owner。 ### ARCH-204 收敛 sync/async 重复与测试存量 diff --git a/docs/architecture-refactor-roadmap.md b/docs/architecture-refactor-roadmap.md index a6778197e..6ca800c16 100644 --- a/docs/architecture-refactor-roadmap.md +++ b/docs/architecture-refactor-roadmap.md @@ -168,7 +168,7 @@ canonical 主程序;兼容只经统一 Compat/SDK 门面提供。 | S5-L1 PluginHelper/PluginManager | `DELIVERED` | S2,S3 | `fa40f29df`:市场/包/依赖/备份/健康服务各归 owner;Facade 只转发稳定 ABI,构造归 typed PluginRuntime | | S5-L2 Agent/LLM provider | `DELIVERED` | S3-L7 | `c000c4fff`、`3a33da0b3`、`dbdf7d058`:catalog、发现、认证、session、runtime 分离;Manager/Facade 只保留稳定 API,旧包根符号仅由精确 Compat 承接 | | S5-L3 Domain projection | `VERIFIED` | S3-L6 | `MediaInfo` canonical 路径和 setter ABI 保留;四来源纯投影归入单词 owner,输入不可变、重复业务语义清零 | -| S5-L4 Startup composition | `PLANNED` | S2,S3,S5-L1,S5-L2 | `initializers/modules.py` 仅负责顺序/注册/重启决策,构造按领域进入 composition | +| S5-L4 Startup composition | `VERIFIED` | S2,S3,S5-L1,S5-L2 | 领域构造全部归单词型 composition owner;HostRuntime/ApiData 同源;显式逆序 reset manifest 在 worker 收敛后撤销 Provider,失败保留完整诊断边界 | | S5-L5 Sync/async 重复清零 | `PLANNED` | S3,S5 | 双 ABI 外壳共享纯逻辑,重复业务实现清零,Session/客户端不跨并发边界复用 | | S5-L6 最终兼容与交付 | `PLANNED` | S3,S5-L1,S5-L2,S5-L3,S5-L4,S5-L5 | canonical 旧实现/重复导出清零;同步官方插件仓验证;锁定全量、Pylint、架构、现有类型/覆盖率门禁全部通过并推送;完成后结束本轮战略任务 | diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 193f8db90..cec2d3a9e 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -172,13 +172,14 @@ autonomous-task repository/execution service set. The runtime owner projects tho objects into `AgentChatRuntime`. Agent composition may accept the Agent initializer's data-context registrar as a callback, but must not import `startup.initializers`; tool-manager, scheduler and Agent activation order remains in -the initializer. Database runtime ownership rejects overlapping lifespans; after a -successful worker shutdown, the -configuration/database composition owners must revoke every provider they published -before releasing database connections. Other domain providers remain owned by their -matching composition batch and must satisfy the same symmetric lifecycle contract -before S5-L4 closes. A failed shutdown retains both the runtime owner and its providers -for diagnosis and retry instead of publishing a second owner. +the initializer. Database runtime ownership rejects overlapping lifespans. After a +successful worker shutdown, `initializers/modules.py` invokes the explicit reverse-order +reset manifest: event/resource/security/Chain/managed-resource/Scheduler/tool/Agent, +then transfer/outbox/workflow/server/runtime/database/network/configuration providers, +and only afterward releases database connections. Startup rollback uses the same reset +manifest and never materializes an owner merely to stop it. A failed database shutdown +retains the runtime owner, every provider and the connection boundary for diagnosis and +retry instead of publishing a second owner or silently entering a partial lifespan. Startup publishes its frozen, slotted `HostRuntime` through FastAPI `app.state`. API dependencies must narrow that object to a domain runtime (for example, `AgentChatRuntime`) instead of adding a string key to a global service map. diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index c49c98d01..c9bf41de6 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -3281,7 +3281,7 @@ "attr-defined": 2, "misc": 1, "no-any-return": 2, - "no-untyped-call": 14, + "no-untyped-call": 11, "no-untyped-def": 6, "return-value": 2 }, diff --git a/tests/test_host_runtime_context.py b/tests/test_host_runtime_context.py index 26fde963c..8a95b4f73 100644 --- a/tests/test_host_runtime_context.py +++ b/tests/test_host_runtime_context.py @@ -357,11 +357,11 @@ async def test_init_modules_cleans_partial_message_owner_on_failure(monkeypatch) "_initialize_modules", failing_initialize_modules, ) - stop_database_runtime = AsyncMock() + stop_modules = AsyncMock(return_value=True) monkeypatch.setattr( modules_initializer, - "stop_database_runtime", - stop_database_runtime, + "stop_modules", + stop_modules, ) with pytest.raises(RuntimeError) as raised: @@ -370,4 +370,4 @@ async def test_init_modules_cleans_partial_message_owner_on_failure(monkeypatch) assert raised.value is startup_error close.assert_called_once_with() assert MessageHelper.get_existing_instance() is None - stop_database_runtime.assert_awaited_once_with() + stop_modules.assert_awaited_once_with() diff --git a/tests/test_lifecycle_shutdown.py b/tests/test_lifecycle_shutdown.py index c59dd27c4..04b76b449 100644 --- a/tests/test_lifecycle_shutdown.py +++ b/tests/test_lifecycle_shutdown.py @@ -1257,16 +1257,9 @@ def test_stop_modules_drains_web_agent_tasks_before_persistence(monkeypatch): "database_runtime_active", lambda: database_state["active"], ) - monkeypatch.setattr( - modules_initializer, - "reset_database_services", - MagicMock(side_effect=lambda: order.append("database-services")), - ) - monkeypatch.setattr( - modules_initializer, - "reset_configuration", - MagicMock(side_effect=lambda: order.append("configuration")), - ) + dependencies["reset_module_providers"].side_effect = lambda: order.append( + "providers" + ) or True dependencies["close_database"].side_effect = lambda: order.append("connection") converged = asyncio.run(modules_initializer.stop_modules()) @@ -1277,12 +1270,65 @@ def test_stop_modules_drains_web_agent_tasks_before_persistence(monkeypatch): "persistence-admission", "persistence", "database", - "database-services", - "configuration", + "providers", "connection", ] +def test_stop_modules_retains_providers_when_database_worker_remains_active( + monkeypatch, +) -> None: + """数据库 worker 未收敛时不得撤销 Provider 或关闭仍被使用的连接。""" + dependencies = _patch_module_shutdown_dependencies(monkeypatch) + persistence = MagicMock() + persistence.begin_shutdown = MagicMock() + persistence.shutdown = AsyncMock() + monkeypatch.setattr( + modules_initializer, + "get_configured_agent_chat_persistence", + MagicMock(return_value=persistence), + ) + monkeypatch.setattr( + modules_initializer, + "stop_database_runtime", + AsyncMock(side_effect=RuntimeError("database busy")), + ) + monkeypatch.setattr( + modules_initializer, + "database_runtime_active", + lambda: True, + ) + + assert asyncio.run(modules_initializer.stop_modules()) is False + dependencies["reset_module_providers"].assert_not_called() + dependencies["close_database"].assert_not_awaited() + + +def test_reset_module_providers_preserves_reverse_order_after_failure( + monkeypatch, +) -> None: + """单个 Provider reset 失败时仍按既定逆序执行全部后续 owner。""" + calls: list[str] = [] + + def fail() -> None: + """记录失败 owner 后抛出异常。""" + calls.append("second") + raise RuntimeError("reset failed") + + monkeypatch.setattr( + modules_initializer, + "_module_provider_reset_steps", + lambda: ( + ("first", lambda: calls.append("first")), + ("second", fail), + ("third", lambda: calls.append("third")), + ), + ) + + assert modules_initializer.reset_module_providers() is False + assert calls == ["first", "second", "third"] + + @pytest.mark.asyncio async def test_stop_modules_cancellation_does_not_skip_database_runtime_cleanup( monkeypatch, @@ -1459,11 +1505,9 @@ def _patch_module_shutdown_dependencies(monkeypatch) -> dict: ): instance = MagicMock() setattr(instance, method_name, MagicMock()) - monkeypatch.setattr( - modules_initializer, - name, - MagicMock(return_value=instance), - ) + instance_type = MagicMock(return_value=instance) + instance_type.get_existing_instance.return_value = instance + monkeypatch.setattr(modules_initializer, name, instance_type) key = name.removesuffix("Helper").removesuffix("Manager").lower() dependencies[key] = getattr(instance, method_name) @@ -1496,29 +1540,20 @@ def _patch_module_shutdown_dependencies(monkeypatch) -> dict: async_redis = MagicMock() async_redis.close = AsyncMock() - monkeypatch.setattr( - modules_initializer, - "AsyncRedisHelper", - MagicMock(return_value=async_redis), - ) + async_redis_type = MagicMock(return_value=async_redis) + async_redis_type.get_existing_instance.return_value = async_redis + monkeypatch.setattr(modules_initializer, "AsyncRedisHelper", async_redis_type) dependencies["async_redis"] = async_redis.close close_database = AsyncMock() monkeypatch.setattr(modules_initializer, "close_database", close_database) dependencies["close_database"] = close_database - reset_database_services = MagicMock() + reset_module_providers = MagicMock(return_value=True) monkeypatch.setattr( modules_initializer, - "reset_database_services", - reset_database_services, + "reset_module_providers", + reset_module_providers, ) - dependencies["reset_database_services"] = reset_database_services - reset_configuration = MagicMock() - monkeypatch.setattr( - modules_initializer, - "reset_configuration", - reset_configuration, - ) - dependencies["reset_configuration"] = reset_configuration + dependencies["reset_module_providers"] = reset_module_providers return dependencies diff --git a/tests/test_module_manager_capability_adapter.py b/tests/test_module_manager_capability_adapter.py index 1fb4610d2..056c67228 100644 --- a/tests/test_module_manager_capability_adapter.py +++ b/tests/test_module_manager_capability_adapter.py @@ -499,13 +499,21 @@ def test_config_event_reloads_same_instance_and_tracks_selector_changes( assert running.events == ["create", "start", "stop", "start", "stop"] -def test_shutdown_is_irreversible(module_manager_harness) -> None: +def test_shutdown_is_irreversible(module_manager_harness, monkeypatch) -> None: """shutdown 撤销全部可见实例,并拒绝通过 load_modules 再次启动。""" manager = module_manager_harness.manager _enable_sample(module_manager_harness.config_values) manager.load_modules() running = manager.get_running_module("SampleModule") assert running is not None + remove_listener = Mock() + unregister_resolver = Mock() + monkeypatch.setattr(eventmanager, "remove_event_listener", remove_listener) + monkeypatch.setattr( + eventmanager, + "unregister_handler_instance_resolver", + unregister_resolver, + ) manager.shutdown() @@ -514,6 +522,11 @@ def test_shutdown_is_irreversible(module_manager_harness) -> None: manager.load_modules() assert manager.get_running_module("SampleModule") is None assert type(running).instances == [running] + remove_listener.assert_called_once_with( + EventType.ConfigChanged, + manager.handle_config_changed, + ) + unregister_resolver.assert_called_once_with("modules") def test_shutdown_reports_unreleased_module_owner(module_manager_harness) -> None: diff --git a/tests/test_scheduler_cache_expiry.py b/tests/test_scheduler_cache_expiry.py index aeee022d9..675a7278e 100644 --- a/tests/test_scheduler_cache_expiry.py +++ b/tests/test_scheduler_cache_expiry.py @@ -78,6 +78,29 @@ def test_scheduler_initializer_stop_awaits_in_running_loop(monkeypatch): scheduler.stop.assert_not_called() +def test_scheduler_initializer_retains_bindings_when_async_stop_fails(monkeypatch): + """Scheduler 未停止时不得撤销仓储、业务能力或 concrete class 登记。""" + scheduler = Mock() + scheduler.stop_async = AsyncMock(side_effect=RuntimeError("scheduler busy")) + reset_bindings = Mock() + monkeypatch.setattr(scheduler_initializer, "Scheduler", Mock(return_value=scheduler)) + monkeypatch.setattr( + scheduler_initializer, + "reset_scheduler_bindings", + reset_bindings, + ) + + async def scenario() -> None: + """等待异步关闭失败并验证 reset 未被调用。""" + result = scheduler_initializer.stop_scheduler() + assert result is not None + with pytest.raises(RuntimeError, match="scheduler busy"): + await result + + asyncio.run(scenario()) + reset_bindings.assert_not_called() + + def test_clear_cache_is_manual_only(monkeypatch): """缓存清理任务应仅手动执行,不注册到调度器自动运行。""" background_scheduler = _BackgroundSchedulerStub()