refactor(startup): enforce symmetric lifecycle cleanup

This commit is contained in:
jxxghp
2026-08-29 18:59:14 +08:00
parent 32cdcfcc2f
commit 2116ccecf9
12 changed files with 305 additions and 95 deletions

View File

@@ -229,6 +229,11 @@ class ModuleManager(metaclass=Singleton):
converged = self._runtime.shutdown(reason="application_shutdown")
self._refresh_running_projection()
if converged:
eventmanager.remove_event_listener(
EventType.ConfigChanged,
self.handle_config_changed,
)
eventmanager.unregister_handler_instance_resolver("modules")
logger.info("模块运行时关闭完成")
else:
logger.error("模块运行时关闭后仍有资源 owner 未收敛")

View File

@@ -14,6 +14,7 @@ from app.application.database import (
DatabaseGovernance,
DatabaseHealthService,
configure_database_governance,
reset_database_governance,
)
from app.application.maintenance import (
DataCleanupService,
@@ -165,6 +166,7 @@ def publish_database_services(composition: DatabaseComposition) -> None:
def reset_database_services() -> None:
"""撤销当前 lifespan 发布的全部数据库派生服务。"""
reset_database_governance()
reset_transaction_runners()
reset_workflow_query()
reset_plugin_persistence()

View File

@@ -1,8 +1,7 @@
import asyncio
import inspect
import sys
from collections.abc import Awaitable
from typing import Callable, cast
from typing import Callable
from app.adapters.cache.redis import AsyncRedisHelper, RedisHelper
@@ -21,11 +20,13 @@ from app.adapters.system.host import SystemUtils
from app.adapters.system.resource import (
ResourceHelper,
configure_resource_version_provider,
reset_resource_version_provider,
)
from app.application.configuration import (
TransferRetryConfig,
configure_transfer_retry_config,
get_configured_system_config,
reset_transfer_retry_config,
)
from app.application.messaging.agent import (
dispatch_web_agent_message_event,
@@ -39,11 +40,11 @@ from app.application.messaging.message import (
MessageHelper,
stop_message,
)
from app.application.module import configure_module_runtime
from app.application.module import configure_module_runtime, reset_module_runtime
from app.application.outbox import configure_outbox_dispatcher
from app.application.security.url import close_image_proxy_block_log_coalescer
from app.application.service import configure_service_directory
from app.application.workflow import configure_workflow_execution
from app.application.service import configure_service_directory, reset_service_directory
from app.application.workflow import configure_workflow_execution, reset_workflow_execution
from app.command import CommandChain
from app.db.adapters.workflow import (
TransactionalWorkflowExecutionService,
@@ -59,6 +60,7 @@ from app.runtime.extensions.module_manager import ModuleManager
from app.runtime.extensions.service_config import (
ServiceConfigHelper,
configure_service_config_reader,
reset_service_config_reader,
)
from app.runtime.log import logger
from app.runtime.settings import (
@@ -72,10 +74,12 @@ from app.schemas.types import EventType, SystemConfigKey
from app.startup.composition.agent import (
compose_agent,
publish_agent_services,
reset_agent_services,
)
from app.startup.composition.chain import (
configure_chain_runtime_context,
configure_wallpaper_services,
reset_chain_services,
)
from app.startup.composition.configuration import (
compose_configuration,
@@ -92,25 +96,39 @@ from app.startup.composition.database import (
start_database_runtime,
stop_database_runtime,
)
from app.startup.composition.network import configure_application_network_ports
from app.startup.composition.outbox import build_outbox_dispatcher
from app.startup.composition.network import (
configure_application_network_ports,
reset_application_network_ports,
)
from app.startup.composition.outbox import build_outbox_dispatcher, reset_outbox_services
from app.startup.composition.runtime import (
RuntimeInputs,
compose_runtime,
compose_runtime_dependencies,
publish_runtime,
reset_runtime,
)
from app.startup.composition.security import (
configure_security_access,
configure_security_services,
reset_security_access,
reset_security_services,
)
from app.startup.composition.server import configure_server_services, reset_server_services
from app.startup.initializers.agent import (
configure_agent_data_context,
init_agent,
reset_agent_data_context,
)
from app.startup.composition.server import configure_server_services
from app.startup.initializers.agent import configure_agent_data_context, init_agent
from app.startup.initializers.resources import (
init_managed_resources,
reset_managed_resources,
stop_managed_resources,
)
from app.startup.initializers.scheduler import configure_scheduler_agent_tasks
from app.startup.initializers.scheduler import (
configure_scheduler_agent_tasks,
reset_scheduler_bindings,
)
def configure_runtime_data_providers() -> None:
@@ -123,6 +141,88 @@ def configure_runtime_data_providers() -> None:
)
def reset_runtime_data_providers() -> None:
"""按发布逆序撤销模块、服务目录和配置读取 Provider。"""
reset_service_directory()
reset_module_runtime()
reset_service_config_reader()
def reset_event_services() -> None:
"""撤销启动组合根登记的事件通知、监听器和宿主 resolver。"""
event_manager = EventManager.get_existing_instance()
if event_manager is None:
return
event_manager.remove_event_listener(
EventType.NoticeMessage,
dispatch_web_agent_message_event,
)
event_manager.unregister_handler_instance_resolver("host")
event_manager.reset_error_notifier()
def reset_tool_services() -> None:
"""撤销工具管理器持有的 Agent 数据上下文与目录快照。"""
from app.agent.tools.manager import moviepilot_tool_manager
moviepilot_tool_manager.reset_data_context()
def _module_provider_reset_steps() -> tuple[tuple[str, Callable[[], object]], ...]:
"""返回当前模块 lifespan 的完整 Provider 逆序撤销清单。"""
return (
("事件服务", reset_event_services),
("资源版本 Provider", reset_resource_version_provider),
("认证访问服务", reset_security_access),
("Chain 服务", reset_chain_services),
("托管资源引用", reset_managed_resources),
("调度器绑定", reset_scheduler_bindings),
("Agent 工具服务", reset_tool_services),
(
"Agent 服务",
lambda: reset_agent_services(
data_context_resetter=reset_agent_data_context,
),
),
("传输重试配置", reset_transfer_retry_config),
("Outbox 服务", reset_outbox_services),
("工作流执行服务", reset_workflow_execution),
("中心服务", reset_server_services),
("运行时数据 Provider", reset_runtime_data_providers),
("HostRuntime 投影", reset_runtime),
("数据库服务", reset_database_services),
("认证服务", reset_security_services),
("应用网络端口", reset_application_network_ports),
("配置服务", reset_configuration),
)
def reset_module_providers() -> bool:
"""逐项撤销模块 Provider;单项失败不跳过后续 owner,并返回整体结果。"""
converged = True
for name, callback in _module_provider_reset_steps():
try:
callback()
except Exception as error:
logger.error("撤销%s失败:%s", name, error)
converged = False
return converged
def _existing_singleton(instance_type: type) -> object | None:
"""读取已经物化的 Singleton,启动失败回滚时禁止反向创建新 owner。"""
getter = getattr(instance_type, "get_existing_instance", None)
return getter() if callable(getter) else None
def _call_existing_singleton(instance_type: type, method_name: str) -> object:
"""调用已存在 Singleton 的关闭方法;owner 未创建时视为已经收敛。"""
instance = _existing_singleton(instance_type)
if instance is None:
return True
return getattr(instance, method_name)()
def notify_event_error(title: str, message: str) -> None:
"""将事件总线错误转发到系统消息通道。"""
MessageHelper().put(
@@ -345,14 +445,36 @@ async def stop_modules() -> bool:
return converged
await run_step("图片代理安全日志合并器", close_image_proxy_block_log_coalescer)
await run_step("模块", lambda: ModuleManager().shutdown(), offload=True)
await run_step("事件消费", lambda: EventManager().stop_async())
await run_step(
"模块",
lambda: _call_existing_singleton(ModuleManager, "shutdown"),
offload=True,
)
await run_step(
"事件消费",
lambda: _call_existing_singleton(EventManager, "stop_async"),
)
await run_step("浏览器会话", close_browser_sessions, offload=True)
await run_step("托管资源", stop_managed_resources)
await run_step("DoH服务", lambda: DohHelper().shutdown(), offload=True)
await run_step("线程池", lambda: ThreadHelper().shutdown(), offload=True)
await run_step("Redis缓存连接", lambda: RedisHelper().close(), offload=True)
await run_step("异步Redis缓存连接", lambda: AsyncRedisHelper().close())
await run_step(
"DoH服务",
lambda: _call_existing_singleton(DohHelper, "shutdown"),
offload=True,
)
await run_step(
"线程池",
lambda: _call_existing_singleton(ThreadHelper, "shutdown"),
offload=True,
)
await run_step(
"Redis缓存连接",
lambda: _call_existing_singleton(RedisHelper, "close"),
offload=True,
)
await run_step(
"异步Redis缓存连接",
lambda: _call_existing_singleton(AsyncRedisHelper, "close"),
)
# Web Agent 的取消 finally 可能还要写入最终展示快照,必须先完成任务收尾,再关闭写入准入。
web_agent_drained = await run_step(
"Web Agent后台任务",
@@ -362,29 +484,38 @@ async def stop_modules() -> bool:
if not web_agent_drained:
web_agent_drained = await run_step("Web Agent后台任务收尾", wait_web_agent_background_tasks)
if web_agent_drained:
await run_step(
"Agent会话持久化准入",
lambda: get_configured_agent_chat_persistence().begin_shutdown(),
)
persistence_drained = await run_step(
"Agent会话持久化",
lambda: get_configured_agent_chat_persistence().shutdown(),
)
try:
persistence = get_configured_agent_chat_persistence()
except RuntimeError:
persistence = None
if persistence is None:
persistence_drained = True
else:
await run_step(
"Agent会话持久化准入",
persistence.begin_shutdown,
)
persistence_drained = await run_step(
"Agent会话持久化",
persistence.shutdown,
)
else:
persistence_drained = False
all_converged = False
logger.error("Web Agent任务未完成收尾,跳过持久化和数据库关闭以保护活动事务")
if persistence_drained:
await run_step("数据库任务", stop_database_runtime)
await run_step("前端服务", stop_frontend, offload=True)
await run_step("临时文件", clear_temp, offload=True)
if persistence_drained:
if not database_runtime_active():
await run_step("数据库服务", reset_database_services)
await run_step("配置服务", reset_configuration)
await run_step("模块 Provider", reset_module_providers)
await run_step("数据库连接", close_database)
else:
all_converged = False
logger.error("数据库任务未收敛,跳过数据库连接关闭以避免运行中事务使用已释放连接")
await run_step("前端服务", stop_frontend, offload=True)
await run_step("临时文件", clear_temp, offload=True)
logger.error(
"数据库任务未收敛,保留全部 Provider 和数据库连接以供诊断与重试"
)
return all_converged
@@ -509,14 +640,8 @@ async def init_modules() -> HostRuntime:
except Exception as cleanup_error: # noqa: BLE001 保留原始启动异常
logger.error(f"模块启动失败后的消息资源清理失败:{cleanup_error}")
try:
await stop_database_runtime()
if await stop_modules() is False:
logger.error("模块启动失败后的资源 owner 未完全收敛")
except Exception as cleanup_error: # noqa: BLE001 保留原始启动异常
logger.error(f"模块启动失败后的数据库任务清理失败:{cleanup_error}")
if not database_runtime_active():
reset_database_services()
reset_configuration()
try:
await cast(Callable[[], Awaitable[None]], close_database)()
except Exception as cleanup_error: # noqa: BLE001 保留原始启动异常
logger.error(f"模块启动失败后的数据库连接清理失败:{cleanup_error}")
logger.error(f"模块启动失败后的统一资源清理失败:{cleanup_error}")
raise

View File

@@ -31,6 +31,15 @@ def reset_scheduler_runtime() -> None:
reset_scheduler_class()
def reset_scheduler_bindings() -> None:
"""在 Scheduler 已停止后撤销当前 lifespan 的仓储、业务能力和类登记。"""
# Singleton 元类保留旧动态接口,当前 owner 在此精确收窄为 Scheduler。
scheduler = Scheduler.get_existing_instance() # type: ignore[no-untyped-call]
if scheduler is not None:
scheduler.reset_runtime_bindings()
reset_scheduler_runtime()
def configure_scheduler_agent_tasks(repository: AgentTaskRepository) -> None:
"""在调度器启动前注入自主任务仓储。"""
configure_scheduler_runtime()
@@ -91,18 +100,14 @@ def stop_scheduler() -> Optional[Awaitable[None]]:
try:
asyncio.get_running_loop()
except RuntimeError:
try:
scheduler.stop()
finally:
reset_scheduler_runtime()
scheduler.stop()
reset_scheduler_bindings()
return None
async def stop_and_reset() -> None:
"""等待异步调度器收口后清除 provider。"""
try:
await scheduler.stop_async()
finally:
reset_scheduler_runtime()
await scheduler.stop_async()
reset_scheduler_bindings()
return stop_and_reset()

View File

@@ -78,7 +78,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
| Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement |
| Python 源码量 | 约 298,700 行 | 60 个文件超过 1,000 行,10 个超过 2,000 行 |
| 长方法 | 299 个超过 80 行 | 70 个超过 150 行,23 个超过 250 行;大量是私有方法 |
| 全量 mypy 历史债务 | 10,320 / 592 文件 | Runtime 唯一组合 owner 零新增类型债,`modules.py` 低水位下降 4;strict frontier 当前覆盖 41 个文件 |
| 全量 mypy 历史债务 | 10,317 / 592 文件 | 生命周期统一撤销接线继续清除 `modules.py` 3 项调用类型债;strict frontier 当前覆盖 41 个文件 |
| Ruff 历史诊断 | 644 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| 覆盖率低水位 | Application 81.00%,Domain 80.67% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
@@ -678,7 +678,8 @@ Pylint 10/10、Ruff、mypy/复杂度 ratchet、宿主与最新官方插件基线
- [x] L4.3 已将 HostRuntime、命名领域 Runtime 和旧 `ApiDataPorts` 投影统一移到单词型
`startup/composition/runtime.py`;`RuntimeDependencies` 由 Agent、Chain、HostRuntime 复用,
`HostRuntime.tasks` 显式绑定 lifecycle 的 TaskRegistry,Chain 不再二次构造 transfer execution。
- [ ] 保留生命周期 manifest 和顺序快照;高扇出在组合根是允许的,但业务实现不能继续沉积其中。
- [x] 生命周期按显式逆序 reset manifest 撤销全部 Provider;数据库 worker 未收敛时保留
HostRuntime、Provider 与连接供诊断重试,启动失败复用同一正式清理路径且不反向物化 owner。
### ARCH-204 收敛 sync/async 重复与测试存量

View File

@@ -168,7 +168,7 @@ canonical 主程序;兼容只经统一 Compat/SDK 门面提供。
| S5-L1 PluginHelper/PluginManager | `DELIVERED` | S2,S3 | `fa40f29df`:市场/包/依赖/备份/健康服务各归 owner;Facade 只转发稳定 ABI,构造归 typed PluginRuntime |
| S5-L2 Agent/LLM provider | `DELIVERED` | S3-L7 | `c000c4fff`、`3a33da0b3`、`dbdf7d058`:catalog、发现、认证、session、runtime 分离;Manager/Facade 只保留稳定 API,旧包根符号仅由精确 Compat 承接 |
| S5-L3 Domain projection | `VERIFIED` | S3-L6 | `MediaInfo` canonical 路径和 setter ABI 保留;四来源纯投影归入单词 owner,输入不可变、重复业务语义清零 |
| S5-L4 Startup composition | `PLANNED` | S2,S3,S5-L1,S5-L2 | `initializers/modules.py` 仅负责顺序/注册/重启决策,构造按领域进入 composition |
| S5-L4 Startup composition | `VERIFIED` | S2,S3,S5-L1,S5-L2 | 领域构造全部归单词型 composition owner;HostRuntime/ApiData 同源;显式逆序 reset manifest 在 worker 收敛后撤销 Provider,失败保留完整诊断边界 |
| S5-L5 Sync/async 重复清零 | `PLANNED` | S3,S5 | 双 ABI 外壳共享纯逻辑,重复业务实现清零,Session/客户端不跨并发边界复用 |
| S5-L6 最终兼容与交付 | `PLANNED` | S3,S5-L1,S5-L2,S5-L3,S5-L4,S5-L5 | canonical 旧实现/重复导出清零;同步官方插件仓验证;锁定全量、Pylint、架构、现有类型/覆盖率门禁全部通过并推送;完成后结束本轮战略任务 |

View File

@@ -172,13 +172,14 @@ autonomous-task repository/execution service set. The runtime owner projects tho
objects into `AgentChatRuntime`. Agent composition may accept
the Agent initializer's data-context registrar as a callback, but must not import
`startup.initializers`; tool-manager, scheduler and Agent activation order remains in
the initializer. Database runtime ownership rejects overlapping lifespans; after a
successful worker shutdown, the
configuration/database composition owners must revoke every provider they published
before releasing database connections. Other domain providers remain owned by their
matching composition batch and must satisfy the same symmetric lifecycle contract
before S5-L4 closes. A failed shutdown retains both the runtime owner and its providers
for diagnosis and retry instead of publishing a second owner.
the initializer. Database runtime ownership rejects overlapping lifespans. After a
successful worker shutdown, `initializers/modules.py` invokes the explicit reverse-order
reset manifest: event/resource/security/Chain/managed-resource/Scheduler/tool/Agent,
then transfer/outbox/workflow/server/runtime/database/network/configuration providers,
and only afterward releases database connections. Startup rollback uses the same reset
manifest and never materializes an owner merely to stop it. A failed database shutdown
retains the runtime owner, every provider and the connection boundary for diagnosis and
retry instead of publishing a second owner or silently entering a partial lifespan.
Startup publishes its frozen, slotted `HostRuntime` through FastAPI `app.state`.
API dependencies must narrow that object to a domain runtime (for example,
`AgentChatRuntime`) instead of adding a string key to a global service map.

View File

@@ -3281,7 +3281,7 @@
"attr-defined": 2,
"misc": 1,
"no-any-return": 2,
"no-untyped-call": 14,
"no-untyped-call": 11,
"no-untyped-def": 6,
"return-value": 2
},

View File

@@ -357,11 +357,11 @@ async def test_init_modules_cleans_partial_message_owner_on_failure(monkeypatch)
"_initialize_modules",
failing_initialize_modules,
)
stop_database_runtime = AsyncMock()
stop_modules = AsyncMock(return_value=True)
monkeypatch.setattr(
modules_initializer,
"stop_database_runtime",
stop_database_runtime,
"stop_modules",
stop_modules,
)
with pytest.raises(RuntimeError) as raised:
@@ -370,4 +370,4 @@ async def test_init_modules_cleans_partial_message_owner_on_failure(monkeypatch)
assert raised.value is startup_error
close.assert_called_once_with()
assert MessageHelper.get_existing_instance() is None
stop_database_runtime.assert_awaited_once_with()
stop_modules.assert_awaited_once_with()

View File

@@ -1257,16 +1257,9 @@ def test_stop_modules_drains_web_agent_tasks_before_persistence(monkeypatch):
"database_runtime_active",
lambda: database_state["active"],
)
monkeypatch.setattr(
modules_initializer,
"reset_database_services",
MagicMock(side_effect=lambda: order.append("database-services")),
)
monkeypatch.setattr(
modules_initializer,
"reset_configuration",
MagicMock(side_effect=lambda: order.append("configuration")),
)
dependencies["reset_module_providers"].side_effect = lambda: order.append(
"providers"
) or True
dependencies["close_database"].side_effect = lambda: order.append("connection")
converged = asyncio.run(modules_initializer.stop_modules())
@@ -1277,12 +1270,65 @@ def test_stop_modules_drains_web_agent_tasks_before_persistence(monkeypatch):
"persistence-admission",
"persistence",
"database",
"database-services",
"configuration",
"providers",
"connection",
]
def test_stop_modules_retains_providers_when_database_worker_remains_active(
monkeypatch,
) -> None:
"""数据库 worker 未收敛时不得撤销 Provider 或关闭仍被使用的连接。"""
dependencies = _patch_module_shutdown_dependencies(monkeypatch)
persistence = MagicMock()
persistence.begin_shutdown = MagicMock()
persistence.shutdown = AsyncMock()
monkeypatch.setattr(
modules_initializer,
"get_configured_agent_chat_persistence",
MagicMock(return_value=persistence),
)
monkeypatch.setattr(
modules_initializer,
"stop_database_runtime",
AsyncMock(side_effect=RuntimeError("database busy")),
)
monkeypatch.setattr(
modules_initializer,
"database_runtime_active",
lambda: True,
)
assert asyncio.run(modules_initializer.stop_modules()) is False
dependencies["reset_module_providers"].assert_not_called()
dependencies["close_database"].assert_not_awaited()
def test_reset_module_providers_preserves_reverse_order_after_failure(
monkeypatch,
) -> None:
"""单个 Provider reset 失败时仍按既定逆序执行全部后续 owner。"""
calls: list[str] = []
def fail() -> None:
"""记录失败 owner 后抛出异常。"""
calls.append("second")
raise RuntimeError("reset failed")
monkeypatch.setattr(
modules_initializer,
"_module_provider_reset_steps",
lambda: (
("first", lambda: calls.append("first")),
("second", fail),
("third", lambda: calls.append("third")),
),
)
assert modules_initializer.reset_module_providers() is False
assert calls == ["first", "second", "third"]
@pytest.mark.asyncio
async def test_stop_modules_cancellation_does_not_skip_database_runtime_cleanup(
monkeypatch,
@@ -1459,11 +1505,9 @@ def _patch_module_shutdown_dependencies(monkeypatch) -> dict:
):
instance = MagicMock()
setattr(instance, method_name, MagicMock())
monkeypatch.setattr(
modules_initializer,
name,
MagicMock(return_value=instance),
)
instance_type = MagicMock(return_value=instance)
instance_type.get_existing_instance.return_value = instance
monkeypatch.setattr(modules_initializer, name, instance_type)
key = name.removesuffix("Helper").removesuffix("Manager").lower()
dependencies[key] = getattr(instance, method_name)
@@ -1496,29 +1540,20 @@ def _patch_module_shutdown_dependencies(monkeypatch) -> dict:
async_redis = MagicMock()
async_redis.close = AsyncMock()
monkeypatch.setattr(
modules_initializer,
"AsyncRedisHelper",
MagicMock(return_value=async_redis),
)
async_redis_type = MagicMock(return_value=async_redis)
async_redis_type.get_existing_instance.return_value = async_redis
monkeypatch.setattr(modules_initializer, "AsyncRedisHelper", async_redis_type)
dependencies["async_redis"] = async_redis.close
close_database = AsyncMock()
monkeypatch.setattr(modules_initializer, "close_database", close_database)
dependencies["close_database"] = close_database
reset_database_services = MagicMock()
reset_module_providers = MagicMock(return_value=True)
monkeypatch.setattr(
modules_initializer,
"reset_database_services",
reset_database_services,
"reset_module_providers",
reset_module_providers,
)
dependencies["reset_database_services"] = reset_database_services
reset_configuration = MagicMock()
monkeypatch.setattr(
modules_initializer,
"reset_configuration",
reset_configuration,
)
dependencies["reset_configuration"] = reset_configuration
dependencies["reset_module_providers"] = reset_module_providers
return dependencies

View File

@@ -499,13 +499,21 @@ def test_config_event_reloads_same_instance_and_tracks_selector_changes(
assert running.events == ["create", "start", "stop", "start", "stop"]
def test_shutdown_is_irreversible(module_manager_harness) -> None:
def test_shutdown_is_irreversible(module_manager_harness, monkeypatch) -> None:
"""shutdown 撤销全部可见实例,并拒绝通过 load_modules 再次启动。"""
manager = module_manager_harness.manager
_enable_sample(module_manager_harness.config_values)
manager.load_modules()
running = manager.get_running_module("SampleModule")
assert running is not None
remove_listener = Mock()
unregister_resolver = Mock()
monkeypatch.setattr(eventmanager, "remove_event_listener", remove_listener)
monkeypatch.setattr(
eventmanager,
"unregister_handler_instance_resolver",
unregister_resolver,
)
manager.shutdown()
@@ -514,6 +522,11 @@ def test_shutdown_is_irreversible(module_manager_harness) -> None:
manager.load_modules()
assert manager.get_running_module("SampleModule") is None
assert type(running).instances == [running]
remove_listener.assert_called_once_with(
EventType.ConfigChanged,
manager.handle_config_changed,
)
unregister_resolver.assert_called_once_with("modules")
def test_shutdown_reports_unreleased_module_owner(module_manager_harness) -> None:

View File

@@ -78,6 +78,29 @@ def test_scheduler_initializer_stop_awaits_in_running_loop(monkeypatch):
scheduler.stop.assert_not_called()
def test_scheduler_initializer_retains_bindings_when_async_stop_fails(monkeypatch):
"""Scheduler 未停止时不得撤销仓储、业务能力或 concrete class 登记。"""
scheduler = Mock()
scheduler.stop_async = AsyncMock(side_effect=RuntimeError("scheduler busy"))
reset_bindings = Mock()
monkeypatch.setattr(scheduler_initializer, "Scheduler", Mock(return_value=scheduler))
monkeypatch.setattr(
scheduler_initializer,
"reset_scheduler_bindings",
reset_bindings,
)
async def scenario() -> None:
"""等待异步关闭失败并验证 reset 未被调用。"""
result = scheduler_initializer.stop_scheduler()
assert result is not None
with pytest.raises(RuntimeError, match="scheduler busy"):
await result
asyncio.run(scenario())
reset_bindings.assert_not_called()
def test_clear_cache_is_manual_only(monkeypatch):
"""缓存清理任务应仅手动执行,不注册到调度器自动运行。"""
background_scheduler = _BackgroundSchedulerStub()