ForcePilot/backend/test/unit/channels/infrastructure/test_host_shutdown.py
Kris 696f5f44ec chore: 完成多模块迭代优化与测试覆盖
这一批提交包含:
1. 配置项敏感字段标记与测试用例修复
2. 路由绑定乐观锁支持与静态路径校验
3. 微信WOC插件能力适配与新增单元测试
4. 多个适配器的接口对齐与测试补全
5. 新增定时任务清理处理器与依赖注入容器测试
6. 错误码体系扩展与整合测试
7. 删除临时验证脚本与代码清理
2026-07-08 12:58:43 +08:00

927 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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: 记录跳过日志,不调用 aclose
fake_logger.info.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