927 lines
35 KiB
Python
927 lines
35 KiB
Python
|
|
"""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
|