ForcePilot/backend/test/unit/channels/infrastructure/test_host_shutdown.py

927 lines
35 KiB
Python
Raw Normal View History

"""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