"""yuxi.channels.infrastructure.host_shutdown 单元测试。 覆盖 ``HostShutdown`` 关停编排行为: - ``shutdown`` 端到端:7 步顺序执行、每步不阻塞后续 - ``_markDraining``:正常标记 / 超时翻译为 ``OperationTimeoutError`` 告警 / 异常告警 - ``_waitForInflightRequests``:有回调 / 无回调 / 超时 / 异常 - ``_teardownPipelines``:清理 ``StageSlotRegistry`` / 超时 / 异常 - ``_stopTransportManager``:成功 / 超时 / 异常 - ``_stopPlugins``:空列表提前返回 / 反向拓扑 / 解析失败回退 / PAUSED 也停止 - ``_stopSinglePlugin``:成功 / failed 结果 / 超时 / 异常 - ``_closeDrivenAdapters``:应用级 ``CloseablePort`` / 非 ``CloseablePort`` 跳过 / 应用级异常继续 / 插件级异常继续 / 空列表 - ``_releaseCoreResources``:取消后台任务(None / done / cancelled / 异常) - ``_cancelBackgroundTask``:None / done / 正常取消 / ``CancelledError`` / 其他异常 不依赖运行中的 Docker 服务,纯单元测试。 """ from __future__ import annotations import asyncio from unittest.mock import AsyncMock, MagicMock import pytest from yuxi.channels.application.extension.config_source_registry import ( ConfigSourceRegistry, ) from yuxi.channels.application.extension.event_bus import EventBus from yuxi.channels.application.extension.event_subscription_registry import ( EventSubscriptionRegistry, ) from yuxi.channels.application.extension.stage_slot_registry import StageSlotRegistry from yuxi.channels.application.lifecycle.plugin_dependency_resolver import ( PluginDependencyResolver, ) from yuxi.channels.application.lifecycle.plugin_lifecycle_manager import ( PluginLifecycleManager, ) from yuxi.channels.application.pipeline.stage_slot_injector import StageSlotInjector from yuxi.channels.application.transport import TransportManager from yuxi.channels.contract.dtos.lifecycle import LifecycleResult from yuxi.channels.contract.plugin.lifecycle import LifecycleState from yuxi.channels.contract.ports.driven.closeable_port import CloseablePort from yuxi.channels.core.registry.plugin_registry import PluginRegistry from yuxi.channels.infrastructure.host_bootstrap import HostBootstrap from yuxi.channels.infrastructure.host_shutdown import HostShutdown pytestmark = pytest.mark.unit # --------------------------------------------------------------------------- # 测试辅助:构造 HostShutdown 与桩依赖 # --------------------------------------------------------------------------- def _make_manifest(plugin_id: str = "test-plugin"): """构造测试用 manifest 桩(仅需 ``id`` 属性)。 使用 ``MagicMock`` 替代真实 ``ChannelManifest``,避免构造 ``provides`` / ``capabilities`` / ``config_schema`` 等大量必填字段。测试仅需访问 ``manifest.id`` 属性。 """ manifest = MagicMock() manifest.id = plugin_id return manifest def _make_shutdown( fake_logger, *, plugin_lifecycle: PluginLifecycleManager | None = None, plugin_registry: PluginRegistry | None = None, resolver: PluginDependencyResolver | None = None, stage_slot_injector: StageSlotInjector | None = None, stage_slot_registry: StageSlotRegistry | None = None, event_subscription_registry: EventSubscriptionRegistry | None = None, config_source_registry: ConfigSourceRegistry | None = None, event_bus: EventBus | None = None, transport_manager: TransportManager | None = None, host_bootstrap: HostBootstrap | None = None, inflight_drain_waiter=None, app_driven_adapters=None, ) -> HostShutdown: """构造测试用 HostShutdown,所有依赖默认为 MagicMock。""" return HostShutdown( plugin_lifecycle_manager=plugin_lifecycle or MagicMock(spec=PluginLifecycleManager), plugin_registry=plugin_registry or MagicMock(spec=PluginRegistry), plugin_dependency_resolver=resolver or MagicMock(spec=PluginDependencyResolver), stage_slot_injector=stage_slot_injector or MagicMock(spec=StageSlotInjector), stage_slot_registry=stage_slot_registry or MagicMock(spec=StageSlotRegistry), event_subscription_registry=event_subscription_registry or MagicMock(spec=EventSubscriptionRegistry), config_source_registry=config_source_registry or MagicMock(spec=ConfigSourceRegistry), event_bus=event_bus or MagicMock(spec=EventBus), transport_manager=transport_manager or MagicMock(spec=TransportManager), logger=fake_logger, host_bootstrap=host_bootstrap or MagicMock(spec=HostBootstrap), inflight_drain_waiter=inflight_drain_waiter, app_driven_adapters=app_driven_adapters, ) # --------------------------------------------------------------------------- # _markDraining # --------------------------------------------------------------------------- @pytest.mark.unit class TestMarkDraining: """_markDraining 标记 draining 状态测试。""" @pytest.mark.asyncio async def test_sets_draining_flag_and_logs_info(self, fake_logger): # Arrange shutdown = _make_shutdown(fake_logger) # Act await shutdown._markDraining() # Assert assert shutdown._draining is True fake_logger.info.assert_any_call("宿主已标记为 draining,停止接受新请求") @pytest.mark.asyncio async def test_sets_draining_flag_via_shutdown(self, fake_logger): """shutdown() 调用后 _draining 应被置为 True。""" # Arrange shutdown = _make_shutdown(fake_logger) # Act await shutdown.shutdown() # Assert assert shutdown._draining is True # --------------------------------------------------------------------------- # _waitForInflightRequests # --------------------------------------------------------------------------- @pytest.mark.unit class TestWaitForInflightRequests: """_waitForInflightRequests 在途请求等待测试。""" @pytest.mark.asyncio async def test_no_waiter_logs_info_and_continues(self, fake_logger): # Arrange: inflight_drain_waiter=None shutdown = _make_shutdown(fake_logger, inflight_drain_waiter=None) # Act await shutdown._waitForInflightRequests(timeout=30.0) # Assert: 记录 info 日志后直接继续 fake_logger.info.assert_any_call( "无在途请求排空回调,直接继续: timeout=30.0s", ) @pytest.mark.asyncio async def test_calls_waiter_with_timeout(self, fake_logger): # Arrange waiter = AsyncMock() shutdown = _make_shutdown(fake_logger, inflight_drain_waiter=waiter) # Act await shutdown._waitForInflightRequests(timeout=15.0) # Assert: waiter 被调用并传入超时参数 waiter.assert_awaited_once_with(15.0) @pytest.mark.asyncio async def test_waiter_timeout_logs_warn(self, fake_logger): """waiter 抛 TimeoutError 时翻译为 OperationTimeoutError 告警。""" # Arrange waiter = AsyncMock(side_effect=TimeoutError()) shutdown = _make_shutdown(fake_logger, inflight_drain_waiter=waiter) # Act: 不抛异常即证明超时被吞并记录告警 await shutdown._waitForInflightRequests(timeout=5.0) # Assert: warn 被调用(_handleStepTimeout 内部调用 warn) fake_logger.warn.assert_called() warn_call = fake_logger.warn.call_args # 验证告警携带超时信息 assert "等待在途请求" in warn_call.args[0] assert warn_call.kwargs.get("timeout_ms") == 5000 @pytest.mark.asyncio async def test_waiter_exception_logs_warn_and_continues(self, fake_logger): """waiter 抛非 TimeoutError 异常时记录告警并继续。""" # Arrange waiter = AsyncMock(side_effect=RuntimeError("drain boom")) shutdown = _make_shutdown(fake_logger, inflight_drain_waiter=waiter) # Act: 不抛异常即证明异常被吞并 await shutdown._waitForInflightRequests(timeout=5.0) # Assert fake_logger.warn.assert_called() assert "等待在途请求异常" in fake_logger.warn.call_args.args[0] # --------------------------------------------------------------------------- # _teardownPipelines # --------------------------------------------------------------------------- @pytest.mark.unit class TestTeardownPipelines: """_teardownPipelines 管道拆除测试。""" @pytest.mark.asyncio async def test_clears_stage_slot_registry(self, fake_logger): # Arrange stage_slot_registry = MagicMock(spec=StageSlotRegistry) stage_slot_registry.findByPipeline = MagicMock(return_value=[]) shutdown = _make_shutdown(fake_logger, stage_slot_registry=stage_slot_registry) # Act await shutdown._teardownPipelines() # Assert: clear 被调用,清理所有阶段槽位注册 stage_slot_registry.clear.assert_called_once() # 三个管道的槽位都被查询(inbound / outbound / control) assert stage_slot_registry.findByPipeline.call_count == 3 @pytest.mark.asyncio async def test_logs_slot_counts(self, fake_logger): """拆除时记录各管道槽位数量。""" # Arrange stage_slot_registry = MagicMock(spec=StageSlotRegistry) # 模拟 inbound 有 2 个槽位,outbound 1 个,control 0 个 stage_slot_registry.findByPipeline = MagicMock(side_effect=[["slot1", "slot2"], ["slot3"], []]) shutdown = _make_shutdown(fake_logger, stage_slot_registry=stage_slot_registry) # Act await shutdown._teardownPipelines() # Assert: info 日志携带槽位数量 info_calls = fake_logger.info.call_args_list teardown_call = next(c for c in info_calls if "管道已拆除" in c.args[0]) assert teardown_call.kwargs.get("inbound_slot_count") == 2 assert teardown_call.kwargs.get("outbound_slot_count") == 1 assert teardown_call.kwargs.get("control_slot_count") == 0 @pytest.mark.asyncio async def test_exception_logs_warn_and_continues(self, fake_logger): """clear 抛异常时记录告警并继续(不中断 shutdown)。""" # Arrange stage_slot_registry = MagicMock(spec=StageSlotRegistry) stage_slot_registry.findByPipeline = MagicMock(side_effect=RuntimeError("registry corrupted")) shutdown = _make_shutdown(fake_logger, stage_slot_registry=stage_slot_registry) # Act: 不抛异常即证明异常被吞并 await shutdown._teardownPipelines() # Assert fake_logger.warn.assert_called() assert "拆除管道异常" in fake_logger.warn.call_args.args[0] # --------------------------------------------------------------------------- # _stopTransportManager # --------------------------------------------------------------------------- @pytest.mark.unit class TestStopTransportManager: """_stopTransportManager 传输引擎管理器停止测试。""" @pytest.mark.asyncio async def test_calls_transport_manager_stop(self, fake_logger): # Arrange transport_manager = MagicMock(spec=TransportManager) transport_manager.stop = AsyncMock() shutdown = _make_shutdown(fake_logger, transport_manager=transport_manager) # Act await shutdown._stopTransportManager() # Assert transport_manager.stop.assert_awaited_once() @pytest.mark.asyncio async def test_stop_exception_logs_warn_and_continues(self, fake_logger): # Arrange transport_manager = MagicMock(spec=TransportManager) transport_manager.stop = AsyncMock(side_effect=RuntimeError("worker dead")) shutdown = _make_shutdown(fake_logger, transport_manager=transport_manager) # Act: 不抛异常即证明异常被吞并 await shutdown._stopTransportManager() # Assert fake_logger.warn.assert_called() assert "停止传输引擎管理器异常" in fake_logger.warn.call_args.args[0] # --------------------------------------------------------------------------- # _stopPlugins / _stopSinglePlugin # --------------------------------------------------------------------------- @pytest.mark.unit class TestStopPlugins: """_stopPlugins 插件停止测试。""" @pytest.mark.asyncio async def test_no_plugins_logs_info_and_returns(self, fake_logger): """无 STARTED/PAUSED 插件时提前返回。""" # Arrange plugin_registry = MagicMock(spec=PluginRegistry) plugin_registry.listPluginsByState = MagicMock(return_value=[]) shutdown = _make_shutdown(fake_logger, plugin_registry=plugin_registry) # Act await shutdown._stopPlugins() # Assert fake_logger.info.assert_any_call("无 STARTED/PAUSED 状态插件可停止") @pytest.mark.asyncio async def test_stops_plugins_in_reverse_topology(self, fake_logger): """按反向依赖拓扑序停止插件。""" # Arrange manifest_a = _make_manifest("plugin-a") manifest_b = _make_manifest("plugin-b") plugin_manifest_a = MagicMock() plugin_manifest_a.manifest = manifest_a plugin_manifest_b = MagicMock() plugin_manifest_b.manifest = manifest_b plugin_registry = MagicMock(spec=PluginRegistry) # STARTED 返回 [a, b],PAUSED 返回 [] plugin_registry.listPluginsByState = MagicMock( side_effect=lambda state: [plugin_manifest_a, plugin_manifest_b] if state == LifecycleState.STARTED else [] ) # resolver 返回 [a, b](a 先,b 后),反向后为 [b, a] resolver = MagicMock(spec=PluginDependencyResolver) resolver.resolve = MagicMock(return_value=[manifest_a, manifest_b]) plugin_lifecycle = MagicMock(spec=PluginLifecycleManager) plugin_lifecycle.stop = AsyncMock(return_value=LifecycleResult(state="stopped")) shutdown = _make_shutdown( fake_logger, plugin_registry=plugin_registry, resolver=resolver, plugin_lifecycle=plugin_lifecycle, ) # Act await shutdown._stopPlugins() # Assert: 按反向序停止,b 先于 a stop_calls = plugin_lifecycle.stop.await_args_list assert stop_calls[0].args == ("plugin-b",) assert stop_calls[1].args == ("plugin-a",) @pytest.mark.asyncio async def test_resolver_failure_falls_back_to_reversed_list(self, fake_logger): """依赖解析失败时回退为列表反转序。""" # Arrange manifest_a = _make_manifest("plugin-a") manifest_b = _make_manifest("plugin-b") plugin_manifest_a = MagicMock() plugin_manifest_a.manifest = manifest_a plugin_manifest_b = MagicMock() plugin_manifest_b.manifest = manifest_b plugin_registry = MagicMock(spec=PluginRegistry) plugin_registry.listPluginsByState = MagicMock( side_effect=lambda state: [plugin_manifest_a, plugin_manifest_b] if state == LifecycleState.STARTED else [] ) # resolver.resolve 抛异常 resolver = MagicMock(spec=PluginDependencyResolver) resolver.resolve = MagicMock(side_effect=RuntimeError("cycle detected")) plugin_lifecycle = MagicMock(spec=PluginLifecycleManager) plugin_lifecycle.stop = AsyncMock(return_value=LifecycleResult(state="stopped")) shutdown = _make_shutdown( fake_logger, plugin_registry=plugin_registry, resolver=resolver, plugin_lifecycle=plugin_lifecycle, ) # Act: 不抛异常即证明回退路径生效 await shutdown._stopPlugins() # Assert: 记录告警 + 仍停止所有插件(反向序 [b, a]) fake_logger.warn.assert_called() stop_calls = plugin_lifecycle.stop.await_args_list assert len(stop_calls) == 2 assert stop_calls[0].args == ("plugin-b",) assert stop_calls[1].args == ("plugin-a",) @pytest.mark.asyncio async def test_paused_plugins_also_stopped(self, fake_logger): """PAUSED 状态插件同样调用 stop 释放资源。""" # Arrange manifest_paused = _make_manifest("paused-plugin") plugin_manifest_paused = MagicMock() plugin_manifest_paused.manifest = manifest_paused plugin_registry = MagicMock(spec=PluginRegistry) # STARTED 返回 [],PAUSED 返回 [paused-plugin] plugin_registry.listPluginsByState = MagicMock( side_effect=lambda state: [plugin_manifest_paused] if state == LifecycleState.PAUSED else [] ) resolver = MagicMock(spec=PluginDependencyResolver) resolver.resolve = MagicMock(return_value=[manifest_paused]) plugin_lifecycle = MagicMock(spec=PluginLifecycleManager) plugin_lifecycle.stop = AsyncMock(return_value=LifecycleResult(state="stopped")) shutdown = _make_shutdown( fake_logger, plugin_registry=plugin_registry, resolver=resolver, plugin_lifecycle=plugin_lifecycle, ) # Act await shutdown._stopPlugins() # Assert: PAUSED 插件被停止 plugin_lifecycle.stop.assert_awaited_once_with("paused-plugin") @pytest.mark.unit class TestStopSinglePlugin: """_stopSinglePlugin 单个插件停止测试。""" @pytest.mark.asyncio async def test_successful_stop_logs_info(self, fake_logger): # Arrange manifest = _make_manifest("ok-plugin") plugin_lifecycle = MagicMock(spec=PluginLifecycleManager) plugin_lifecycle.stop = AsyncMock(return_value=LifecycleResult(state="stopped")) shutdown = _make_shutdown(fake_logger, plugin_lifecycle=plugin_lifecycle) # Act await shutdown._stopSinglePlugin(manifest) # Assert plugin_lifecycle.stop.assert_awaited_once_with("ok-plugin") fake_logger.info.assert_any_call( "插件已停止: plugin_id=ok-plugin", plugin_id="ok-plugin", ) @pytest.mark.asyncio async def test_failed_result_logs_warn(self, fake_logger): """stop 返回 state=failed 时记录告警。""" # Arrange manifest = _make_manifest("bad-plugin") plugin_lifecycle = MagicMock(spec=PluginLifecycleManager) plugin_lifecycle.stop = AsyncMock(return_value=LifecycleResult(state="failed", error="cleanup failed")) shutdown = _make_shutdown(fake_logger, plugin_lifecycle=plugin_lifecycle) # Act await shutdown._stopSinglePlugin(manifest) # Assert fake_logger.warn.assert_called() warn_call = fake_logger.warn.call_args assert "bad-plugin" in warn_call.args[0] assert warn_call.kwargs.get("error") == "cleanup failed" @pytest.mark.asyncio async def test_stop_exception_logs_warn_and_returns(self, fake_logger): """stop 抛异常时记录告警并返回(不阻塞后续插件)。""" # Arrange manifest = _make_manifest("crash-plugin") plugin_lifecycle = MagicMock(spec=PluginLifecycleManager) plugin_lifecycle.stop = AsyncMock(side_effect=RuntimeError("stop crash")) shutdown = _make_shutdown(fake_logger, plugin_lifecycle=plugin_lifecycle) # Act: 不抛异常即证明异常被吞并 await shutdown._stopSinglePlugin(manifest) # Assert fake_logger.warn.assert_called() assert "crash-plugin" in fake_logger.warn.call_args.args[0] # --------------------------------------------------------------------------- # _closeDrivenAdapters / _closeAllDrivenAdapters # --------------------------------------------------------------------------- @pytest.mark.unit class TestCloseDrivenAdapters: """_closeDrivenAdapters / _closeAllDrivenAdapters 适配器关闭测试。""" @pytest.mark.asyncio async def test_closes_app_level_closeable_port(self, fake_logger): """应用级适配器实现 CloseablePort 时调用 aclose。""" # Arrange app_adapter = MagicMock(spec=CloseablePort) app_adapter.aclose = AsyncMock() plugin_registry = MagicMock(spec=PluginRegistry) plugin_registry.listPluginAdapters = MagicMock(return_value=[]) shutdown = _make_shutdown( fake_logger, plugin_registry=plugin_registry, app_driven_adapters=[app_adapter], ) # Act await shutdown._closeAllDrivenAdapters() # Assert app_adapter.aclose.assert_awaited_once() fake_logger.info.assert_any_call( "应用级被驱动适配器已关闭", adapter_type=type(app_adapter).__name__, ) @pytest.mark.asyncio async def test_skips_non_closeable_app_adapter(self, fake_logger): """应用级适配器未实现 CloseablePort 时跳过。""" # Arrange non_closeable = MagicMock() # 不实现 CloseablePort plugin_registry = MagicMock(spec=PluginRegistry) plugin_registry.listPluginAdapters = MagicMock(return_value=[]) shutdown = _make_shutdown( fake_logger, plugin_registry=plugin_registry, app_driven_adapters=[non_closeable], ) # Act await shutdown._closeAllDrivenAdapters() # Assert: 记录跳过日志(DEBUG 级别,因跳过是预期行为而非异常事件), # 不调用 aclose fake_logger.debug.assert_any_call( "应用级被驱动适配器未实现 CloseablePort,跳过", adapter_type=type(non_closeable).__name__, ) non_closeable.aclose.assert_not_called() @pytest.mark.asyncio async def test_app_adapter_exception_continues_others(self, fake_logger): """应用级适配器 aclose 抛异常时记录告警并继续关闭其他。""" # Arrange bad_adapter = MagicMock(spec=CloseablePort) bad_adapter.aclose = AsyncMock(side_effect=RuntimeError("close fail")) good_adapter = MagicMock(spec=CloseablePort) good_adapter.aclose = AsyncMock() plugin_registry = MagicMock(spec=PluginRegistry) plugin_registry.listPluginAdapters = MagicMock(return_value=[]) shutdown = _make_shutdown( fake_logger, plugin_registry=plugin_registry, app_driven_adapters=[bad_adapter, good_adapter], ) # Act await shutdown._closeAllDrivenAdapters() # Assert: bad_adapter 抛异常后 good_adapter 仍被关闭 bad_adapter.aclose.assert_awaited_once() good_adapter.aclose.assert_awaited_once() fake_logger.warn.assert_called() @pytest.mark.asyncio async def test_closes_plugin_level_adapters(self, fake_logger): """插件级 DrivenAdapters.close() 被调用。""" # Arrange plugin_adapters = MagicMock() plugin_adapters.close = AsyncMock() plugin_registry = MagicMock(spec=PluginRegistry) plugin_registry.listPluginAdapters = MagicMock(return_value=[("feishu", plugin_adapters)]) shutdown = _make_shutdown( fake_logger, plugin_registry=plugin_registry, app_driven_adapters=None, ) # Act await shutdown._closeAllDrivenAdapters() # Assert plugin_adapters.close.assert_awaited_once() fake_logger.info.assert_any_call( "被驱动适配器已关闭: channel_type=feishu", channel_type="feishu", ) @pytest.mark.asyncio async def test_plugin_adapter_exception_continues_others(self, fake_logger): """插件级适配器 close 抛异常时记录告警并继续。""" # Arrange bad_adapters = MagicMock() bad_adapters.close = AsyncMock(side_effect=RuntimeError("plugin close fail")) good_adapters = MagicMock() good_adapters.close = AsyncMock() plugin_registry = MagicMock(spec=PluginRegistry) plugin_registry.listPluginAdapters = MagicMock( return_value=[("feishu", bad_adapters), ("wecom", good_adapters)] ) shutdown = _make_shutdown( fake_logger, plugin_registry=plugin_registry, app_driven_adapters=None, ) # Act await shutdown._closeAllDrivenAdapters() # Assert: bad 抛异常后 good 仍被关闭 bad_adapters.close.assert_awaited_once() good_adapters.close.assert_awaited_once() fake_logger.warn.assert_called() @pytest.mark.asyncio async def test_empty_plugin_adapters_logs_info(self, fake_logger): """无插件级被驱动适配器时记录 info。""" # Arrange plugin_registry = MagicMock(spec=PluginRegistry) plugin_registry.listPluginAdapters = MagicMock(return_value=[]) shutdown = _make_shutdown( fake_logger, plugin_registry=plugin_registry, app_driven_adapters=None, ) # Act await shutdown._closeAllDrivenAdapters() # Assert fake_logger.info.assert_any_call("无被驱动适配器需要关闭") # --------------------------------------------------------------------------- # _releaseCoreResources / _cancelBackgroundTask # --------------------------------------------------------------------------- @pytest.mark.unit class TestReleaseCoreResources: """_releaseCoreResources / _releaseCoreResourcesInternal 核心资源释放测试。""" @pytest.mark.asyncio async def test_cancels_background_tasks_in_reverse_order(self, fake_logger): """按启动反向序取消后台任务(transport_manager 先于 plugin_reload)。""" # Arrange host_bootstrap = MagicMock(spec=HostBootstrap) transport_task = asyncio.create_task(asyncio.sleep(100)) reload_task = asyncio.create_task(asyncio.sleep(100)) host_bootstrap.transportManagerTask = transport_task host_bootstrap.pluginReloadTask = reload_task shutdown = _make_shutdown(fake_logger, host_bootstrap=host_bootstrap) # Act await shutdown._releaseCoreResourcesInternal() # Assert: 两个任务都被取消 assert transport_task.cancelled() or transport_task.done() assert reload_task.cancelled() or reload_task.done() fake_logger.info.assert_any_call("领域核心资源已释放") @pytest.mark.asyncio async def test_none_tasks_are_skipped(self, fake_logger): """bootstrap 未执行时任务为 None,跳过取消。""" # Arrange host_bootstrap = MagicMock(spec=HostBootstrap) host_bootstrap.transportManagerTask = None host_bootstrap.pluginReloadTask = None shutdown = _make_shutdown(fake_logger, host_bootstrap=host_bootstrap) # Act: 不抛异常即证明 None 路径安全 await shutdown._releaseCoreResourcesInternal() # Assert fake_logger.info.assert_any_call("领域核心资源已释放") @pytest.mark.asyncio async def test_done_tasks_are_skipped(self, fake_logger): """已完成的任务跳过取消。""" # Arrange host_bootstrap = MagicMock(spec=HostBootstrap) # 构造已完成的任务 done_transport = asyncio.get_event_loop().create_future() done_transport.set_result(None) done_reload = asyncio.get_event_loop().create_future() done_reload.set_result(None) # 包装为 Task transport_task = asyncio.ensure_future(done_transport) reload_task = asyncio.ensure_future(done_reload) await asyncio.gather(transport_task, reload_task) host_bootstrap.transportManagerTask = transport_task host_bootstrap.pluginReloadTask = reload_task shutdown = _make_shutdown(fake_logger, host_bootstrap=host_bootstrap) # Act await shutdown._releaseCoreResourcesInternal() # Assert: 已完成的任务不会被取消,但仍记录资源释放日志 fake_logger.info.assert_any_call("领域核心资源已释放") @pytest.mark.unit class TestCancelBackgroundTask: """_cancelBackgroundTask 单任务取消测试。""" @pytest.mark.asyncio async def test_none_task_returns_immediately(self, fake_logger): # Arrange shutdown = _make_shutdown(fake_logger) # Act await shutdown._cancelBackgroundTask(None, "none-task") # Assert: 不抛异常即证明 None 路径安全 # 不记录"已取消"日志 info_calls = [c.args[0] for c in fake_logger.info.call_args_list if c.args] assert not any("none-task已取消" in msg for msg in info_calls) @pytest.mark.asyncio async def test_done_task_returns_immediately(self, fake_logger): # Arrange shutdown = _make_shutdown(fake_logger) done_task = asyncio.get_event_loop().create_future() done_task.set_result(None) done_task_obj = asyncio.ensure_future(done_task) await done_task_obj # Act await shutdown._cancelBackgroundTask(done_task_obj, "done-task") # Assert: 不记录"已取消"日志 info_calls = [c.args[0] for c in fake_logger.info.call_args_list if c.args] assert not any("done-task已取消" in msg for msg in info_calls) @pytest.mark.asyncio async def test_running_task_cancelled_logs_info(self, fake_logger): # Arrange shutdown = _make_shutdown(fake_logger) running_task = asyncio.create_task(asyncio.sleep(100)) # Act await shutdown._cancelBackgroundTask(running_task, "running-task") # Assert assert running_task.cancelled() or running_task.done() fake_logger.info.assert_any_call("running-task已取消") @pytest.mark.asyncio async def test_task_raising_non_cancelled_error_logs_warn(self, fake_logger): """被取消的任务抛非 CancelledError 异常时记录告警。""" # Arrange shutdown = _make_shutdown(fake_logger) async def _raise_on_await() -> None: # 任务被 cancel 后 await 时会抛 CancelledError,这里模拟抛其他异常 try: await asyncio.sleep(100) except asyncio.CancelledError: raise RuntimeError("cleanup error") running_task = asyncio.create_task(_raise_on_await()) # 让任务开始执行(进入 try 块),否则 cancel 在协程启动前触发, # CancelledError 不会被 try/except 捕获,也就不会转为 RuntimeError await asyncio.sleep(0) # Act: 不抛异常即证明异常被吞并 await shutdown._cancelBackgroundTask(running_task, "raising-task") # Assert: 记录告警 fake_logger.warn.assert_called() warn_msg = fake_logger.warn.call_args.args[0] assert "raising-task" in warn_msg # --------------------------------------------------------------------------- # shutdown 端到端 # --------------------------------------------------------------------------- @pytest.mark.unit class TestShutdownEndToEnd: """shutdown() 端到端 7 步顺序执行测试。""" @pytest.mark.asyncio async def test_full_shutdown_executes_all_steps(self, fake_logger): """成功路径:7 步全部执行,记录开始与完成日志。""" # Arrange: 构造所有依赖的桩,配置为成功路径 stage_slot_registry = MagicMock(spec=StageSlotRegistry) stage_slot_registry.findByPipeline = MagicMock(return_value=[]) stage_slot_registry.clear = MagicMock() transport_manager = MagicMock(spec=TransportManager) transport_manager.stop = AsyncMock() plugin_registry = MagicMock(spec=PluginRegistry) plugin_registry.listPluginsByState = MagicMock(return_value=[]) plugin_registry.listPluginAdapters = MagicMock(return_value=[]) host_bootstrap = MagicMock(spec=HostBootstrap) host_bootstrap.transportManagerTask = None host_bootstrap.pluginReloadTask = None shutdown = _make_shutdown( fake_logger, plugin_registry=plugin_registry, stage_slot_registry=stage_slot_registry, transport_manager=transport_manager, host_bootstrap=host_bootstrap, inflight_drain_waiter=None, app_driven_adapters=None, ) # Act await shutdown.shutdown() # Assert: 关键步骤被调用 assert shutdown._draining is True stage_slot_registry.clear.assert_called_once() transport_manager.stop.assert_awaited_once() # 记录开始与完成日志 fake_logger.info.assert_any_call("宿主关停开始") fake_logger.info.assert_any_call("宿主关停完成") @pytest.mark.asyncio async def test_step_failure_does_not_block_subsequent_steps(self, fake_logger): """任一步骤异常不中断后续步骤(INV-9 回退路径完整性)。""" # Arrange: transport_manager.stop 抛异常,但后续 _stopPlugins 仍执行 stage_slot_registry = MagicMock(spec=StageSlotRegistry) stage_slot_registry.findByPipeline = MagicMock(return_value=[]) stage_slot_registry.clear = MagicMock() transport_manager = MagicMock(spec=TransportManager) transport_manager.stop = AsyncMock(side_effect=RuntimeError("stop boom")) plugin_registry = MagicMock(spec=PluginRegistry) plugin_registry.listPluginsByState = MagicMock(return_value=[]) plugin_registry.listPluginAdapters = MagicMock(return_value=[]) host_bootstrap = MagicMock(spec=HostBootstrap) host_bootstrap.transportManagerTask = None host_bootstrap.pluginReloadTask = None shutdown = _make_shutdown( fake_logger, plugin_registry=plugin_registry, stage_slot_registry=stage_slot_registry, transport_manager=transport_manager, host_bootstrap=host_bootstrap, ) # Act: 不抛异常即证明 transport_manager 异常不阻塞后续步骤 await shutdown.shutdown() # Assert: transport_manager.stop 异常记录告警,shutdown 仍完成 fake_logger.warn.assert_called() fake_logger.info.assert_any_call("宿主关停完成") @pytest.mark.asyncio async def test_shutdown_calls_inflight_waiter_when_provided(self, fake_logger): """提供 inflight_drain_waiter 时 shutdown 调用之。""" # Arrange waiter = AsyncMock() stage_slot_registry = MagicMock(spec=StageSlotRegistry) stage_slot_registry.findByPipeline = MagicMock(return_value=[]) stage_slot_registry.clear = MagicMock() plugin_registry = MagicMock(spec=PluginRegistry) plugin_registry.listPluginsByState = MagicMock(return_value=[]) plugin_registry.listPluginAdapters = MagicMock(return_value=[]) transport_manager = MagicMock(spec=TransportManager) transport_manager.stop = AsyncMock() host_bootstrap = MagicMock(spec=HostBootstrap) host_bootstrap.transportManagerTask = None host_bootstrap.pluginReloadTask = None shutdown = _make_shutdown( fake_logger, plugin_registry=plugin_registry, stage_slot_registry=stage_slot_registry, transport_manager=transport_manager, host_bootstrap=host_bootstrap, inflight_drain_waiter=waiter, ) # Act await shutdown.shutdown() # Assert: waiter 被调用,传入 SHUTDOWN_TIMEOUT waiter.assert_awaited_once() assert waiter.await_args.args[0] == HostShutdown.SHUTDOWN_TIMEOUT