From b8fd1b9958fc615f63c075c32f8a24f66df6c686 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Fri, 4 Sep 2026 03:43:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=9D=9E=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E9=85=8D=E7=BD=AE=E5=8F=98=E6=9B=B4=E6=9C=AA?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E7=83=AD=E9=87=8D=E8=BD=BD=20(#6569)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(event): bind config reload lifecycle owners * chore(architecture): update config reload dependency baseline * fix(event): preserve config resolver type contracts --- app/runtime/extensions/plugin/manager.py | 11 +- app/startup/composition/network.py | 5 + app/startup/initializers/modules.py | 70 +++++ docs/architecture-overview.md | 2 +- docs/architecture/optimization-checklist.md | 2 +- .../architecture/dependency-baseline.json | 9 +- tests/test_config_reload_handler.py | 288 +++++++++++++++++- tests/test_event_runtime_components.py | 77 ++++- tests/test_residual_provider_resets.py | 56 +++- 9 files changed, 511 insertions(+), 9 deletions(-) diff --git a/app/runtime/extensions/plugin/manager.py b/app/runtime/extensions/plugin/manager.py index 20b0771f6..10be37caa 100644 --- a/app/runtime/extensions/plugin/manager.py +++ b/app/runtime/extensions/plugin/manager.py @@ -16,6 +16,7 @@ from typing import ( Tuple, Type, Union, + cast, ) from watchfiles import watch @@ -167,7 +168,13 @@ def _resolve_plugin_handler_instance( owner_class: Type[Any], ) -> Optional[EventHandlerBinding]: """通过当前插件管理器单例解析实例,避免事件总线持有过期对象。""" - return PluginManager().resolve_event_handler_instance(owner_class) + manager = cast( + Optional["PluginManager"], + PluginManager.get_existing_instance(), + ) + if manager is None: + return None + return manager.resolve_event_handler_instance(owner_class) @observe_compat_facade("PluginManager") @@ -224,7 +231,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """为插件声明的事件方法解析当前运行实例。""" plugin_id = owner_class.__name__ # 旧测试与部分扩展会替换私有映射来构造隔离运行态,解析器继续尊重该接缝。 - if plugin_id not in self._plugins: + if self._plugins.get(plugin_id) is not owner_class: return None plugin = self._running_plugins.get(plugin_id) owner_name = plugin_id diff --git a/app/startup/composition/network.py b/app/startup/composition/network.py index d149637fb..e899682a0 100644 --- a/app/startup/composition/network.py +++ b/app/startup/composition/network.py @@ -383,6 +383,11 @@ def configure_doh_composition() -> None: DohHelper() +def get_existing_doh_composition() -> object | None: + """返回已由组合根物化的 DoH Adapter,不触发资源创建。""" + return cast(Optional[DohHelper], DohHelper.get_existing_instance()) + + def stop_doh_composition() -> bool: """关闭已存在的 DoH Adapter,停机阶段不得反向物化实例。""" helper = DohHelper.get_existing_instance() diff --git a/app/startup/initializers/modules.py b/app/startup/initializers/modules.py index de4aefd66..c72f554c8 100644 --- a/app/startup/initializers/modules.py +++ b/app/startup/initializers/modules.py @@ -96,6 +96,7 @@ from app.startup.composition.enrichment import ( from app.startup.composition.network import ( configure_application_network_ports, configure_doh_composition, + get_existing_doh_composition, reset_application_network_ports, stop_doh_composition, ) @@ -199,6 +200,7 @@ def reset_event_services() -> None: dispatch_web_agent_message_event, ) event_manager.unregister_handler_instance_resolver("host") + event_manager.unregister_handler_instance_resolver("config_reload") event_manager.reset_error_notifier() @@ -296,6 +298,72 @@ def get_host_event_handler_factories() -> dict[type, Callable[[], object]]: } +def get_config_reload_handler_providers( +) -> dict[type, Callable[[], object | None]]: + """返回未由专属运行时接管的配置重载 owner Provider。""" + from app.chain.transfer.facade import TransferChain + from app.monitor.monitor import Monitor + from app.runtime.state import SystemHelper + + # SystemHelper 不持有外部资源;闭包为当前 lifespan 保留稳定身份。 + system_helper = SystemHelper() + providers: dict[type, Callable[[], object | None]] = { + SystemHelper: lambda: system_helper, + RedisHelper: RedisHelper.get_existing_instance, + AsyncRedisHelper: AsyncRedisHelper.get_existing_instance, + TransferChain: TransferChain.get_existing_instance, + Monitor: Monitor.get_existing_instance, + } + doh_helper = get_existing_doh_composition() + if doh_helper is not None: + providers[type(doh_helper)] = get_existing_doh_composition + plugin_manager = get_existing_plugin_manager() + if plugin_manager is not None: + providers[type(plugin_manager)] = get_existing_plugin_manager + return providers + + +def configure_config_reload_event_handler_resolver() -> None: + """绑定当前生命周期已拥有的配置重载实例,惰性 owner 未创建时跳过。""" + providers = get_config_reload_handler_providers() + runtime_providers = ( + get_existing_doh_composition, + get_existing_plugin_manager, + ) + + def resolve(owner_class: type) -> EventHandlerBinding | None: + """按类型身份读取当前 owner,不在事件分发路径构造资源。""" + provider = providers.get(owner_class) + if provider is not None: + instance = provider() + if instance is not None and type(instance) is not owner_class: + return None + return EventHandlerBinding( + instance=instance, + owner_name=owner_class.__name__, + ) + instance = next( + ( + current + for runtime_provider in runtime_providers + if (current := runtime_provider()) is not None + and type(current) is owner_class + ), + None, + ) + if instance is None: + return None + return EventHandlerBinding( + instance=instance, + owner_name=owner_class.__name__, + ) + + EventManager().register_handler_instance_resolver( # type: ignore[no-untyped-call] + "config_reload", + resolve, + ) + + def configure_host_event_handler_resolver() -> None: """显式登记宿主内置类处理器,禁止事件总线按类名临时构造未知对象。""" factories = get_host_event_handler_factories() @@ -665,6 +733,8 @@ async def _initialize_modules() -> HostRuntime: ) # 宿主类处理器在启动层显式登记,事件总线不再兜底 owner_class()。 configure_host_event_handler_resolver() + # 配置 owner 按各自生命周期读取当前实例,惰性资源不会因事件而物化。 + configure_config_reload_event_handler_resolver() # 加载模块 ModuleManager() # 启动事件消费 diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 9bd2e4a76..3699d16bd 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -755,7 +755,7 @@ flowchart LR | 指标 | 当前值 | |---|---:| | Python 模块 | 968 | -| 内部导入边 | 8,127 | +| 内部导入边 | 8,132 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 53(债务已清零,53 条精确 containment) | diff --git a/docs/architecture/optimization-checklist.md b/docs/architecture/optimization-checklist.md index b7f588a52..86ec7632d 100644 --- a/docs/architecture/optimization-checklist.md +++ b/docs/architecture/optimization-checklist.md @@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 968 / 8,127 | `dependency-baseline.json` 当前快照 | +| 宿主 Python 模块 / 内部依赖边 | 968 / 8,132 | `dependency-baseline.json` 当前快照 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index eb9841dae..0b6a0d7bb 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1089,8 +1089,8 @@ "runtime_only": true } }, - "edge_count": 8127, - "edge_sha256": "b355003c1ca55cd75ed454e9fe986dcf37b37078e6583563b41038c62a05c57f", + "edge_count": 8132, + "edge_sha256": "33505d1acbf26b53533027ed633ec23f3b4c4ee24c242a02372c05493c2919ab", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -8831,10 +8831,14 @@ "app.startup.initializers.modules -> app.chain.site", "app.startup.initializers.modules -> app.chain.subscribe", "app.startup.initializers.modules -> app.chain.subscribe.facade", + "app.startup.initializers.modules -> app.chain.transfer", + "app.startup.initializers.modules -> app.chain.transfer.facade", "app.startup.initializers.modules -> app.chain.workflow", "app.startup.initializers.modules -> app.command", "app.startup.initializers.modules -> app.db", "app.startup.initializers.modules -> app.db.session", + "app.startup.initializers.modules -> app.monitor", + "app.startup.initializers.modules -> app.monitor.monitor", "app.startup.initializers.modules -> app.runtime", "app.startup.initializers.modules -> app.runtime.config", "app.startup.initializers.modules -> app.runtime.events", @@ -8845,6 +8849,7 @@ "app.startup.initializers.modules -> app.runtime.extensions.service", "app.startup.initializers.modules -> app.runtime.log", "app.startup.initializers.modules -> app.runtime.settings", + "app.startup.initializers.modules -> app.runtime.state", "app.startup.initializers.modules -> app.runtime.tasks", "app.startup.initializers.modules -> app.runtime.thread", "app.startup.initializers.modules -> app.scheduler", diff --git a/tests/test_config_reload_handler.py b/tests/test_config_reload_handler.py index 9789b0cd0..441a9d971 100644 --- a/tests/test_config_reload_handler.py +++ b/tests/test_config_reload_handler.py @@ -5,13 +5,28 @@ ConfigReloadMixin 动态生成的事件处理器,必须能被事件总线解 """ import inspect +from unittest.mock import AsyncMock, Mock import pytest +from app.adapters.cache.redis import AsyncRedisHelper, RedisHelper +from app.adapters.network.doh import DohHelper +from app.chain.scraping import ScrapingChain +from app.chain.transfer.facade import TransferChain +from app.foundation.singleton import Singleton, SingletonClass +from app.monitor.monitor import Monitor from app.runtime.events import Event, EventHandlerBinding, eventmanager +from app.runtime.extensions.plugin.manager import PluginManager from app.runtime.reload import ConfigReloadMixin -from app.schemas import ConfigChangeEventData +from app.runtime.state import SystemHelper +from app.scheduler.facade import Scheduler +from app.schemas.event import ConfigChangeEventData from app.schemas.types import EventType +from app.startup.initializers.modules import ( + configure_config_reload_event_handler_resolver, + configure_host_event_handler_resolver, + get_config_reload_handler_providers, +) class _ReloadRecorder(ConfigReloadMixin): @@ -67,6 +82,12 @@ def _build_event(keys): return Event(EventType.ConfigChanged, ConfigChangeEventData(key=set(keys))) +def _configure_lifespan_resolvers() -> None: + """按启动组合顺序登记宿主和配置 owner resolver。""" + configure_host_event_handler_resolver() + configure_config_reload_event_handler_resolver() + + async def _dispatch(instance, event): """按处理器类型走事件总线的同步或异步调用路径。""" handler = instance.__class__.handle_config_changed @@ -146,3 +167,268 @@ def test_externally_managed_reload_class_does_not_register_listener(monkeypatch) assert registrations == [] assert "handle_config_changed" not in _ExternallyManagedReloadRecorder.__dict__ + + +def test_config_reload_owner_is_skipped_without_lifecycle_resolver(monkeypatch): + """非模块配置 owner 缺少 resolver 时不得被事件总线临时构造。""" + helper = object.__new__(DohHelper) + reload_config = Mock() + helper.on_config_changed = reload_config + monkeypatch.setattr( + eventmanager, + "_EventManager__handler_instance_resolvers", + {}, + ) + + eventmanager._EventManager__invoke_handler_by_type_sync( + DohHelper.handle_config_changed, + _build_event({"DOH_ENABLE"}), + ) + + reload_config.assert_not_called() + assert ( + "app.adapters.network.doh.DohHelper.handle_config_changed" + in eventmanager.unresolved_handler_bindings() + ) + + +def test_host_config_reload_resolver_binds_current_doh_owner(monkeypatch): + """DoH 配置事件必须绑定到已由组合根物化的当前 Adapter。""" + helper = object.__new__(DohHelper) + reload_config = Mock() + helper.on_config_changed = reload_config + singleton_key = (DohHelper, (), frozenset()) + monkeypatch.setitem(Singleton._instances, singleton_key, helper) + monkeypatch.setattr( + eventmanager, + "_EventManager__handler_instance_resolvers", + {}, + ) + _configure_lifespan_resolvers() + + eventmanager._EventManager__invoke_handler_by_type_sync( + DohHelper.handle_config_changed, + _build_event({"DOH_ENABLE"}), + ) + + reload_config.assert_called_once_with() + + eventmanager._EventManager__invoke_handler_by_type_sync( + DohHelper.handle_config_changed, + _build_event({"OTHER_KEY"}), + ) + + reload_config.assert_called_once_with() + + +def test_host_resolver_binds_current_scraping_owner(monkeypatch): + """宿主 resolver 必须把同步重载处理器绑定到当前 Chain 单例。""" + chain = object.__new__(ScrapingChain) + reload_config = Mock() + chain.on_config_changed = reload_config + singleton_key = (ScrapingChain, (), frozenset()) + monkeypatch.setitem(Singleton._instances, singleton_key, chain) + monkeypatch.setattr( + eventmanager, + "_EventManager__handler_instance_resolvers", + {}, + ) + configure_host_event_handler_resolver() + + eventmanager._EventManager__invoke_handler_by_type_sync( + ScrapingChain.handle_config_changed, + _build_event({"ScrapingSwitchs"}), + ) + + reload_config.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_host_resolver_binds_current_scheduler_owner(monkeypatch): + """宿主 resolver 必须把异步重载处理器绑定到当前 Scheduler 单例。""" + scheduler = object.__new__(Scheduler) + reload_config = AsyncMock() + scheduler.on_config_changed = reload_config + monkeypatch.setitem(SingletonClass._instances, Scheduler, scheduler) + monkeypatch.setattr( + eventmanager, + "_EventManager__handler_instance_resolvers", + {}, + ) + configure_host_event_handler_resolver() + + await eventmanager._EventManager__invoke_handler_by_type_async( + Scheduler.handle_config_changed, + _build_event({"DEV"}), + ) + + reload_config.assert_awaited_once_with() + + +def test_config_reload_resolver_reads_latest_singleton_owner(monkeypatch): + """resolver 每次读取当前单例,不能保留已经换代的 Redis 实例。""" + first = object.__new__(RedisHelper) + first_reload = Mock() + first.on_config_changed = first_reload + second = object.__new__(RedisHelper) + second_reload = Mock() + second.on_config_changed = second_reload + singleton_key = (RedisHelper, (), frozenset()) + monkeypatch.setitem(Singleton._instances, singleton_key, first) + monkeypatch.setattr( + eventmanager, + "_EventManager__handler_instance_resolvers", + {}, + ) + _configure_lifespan_resolvers() + + eventmanager._EventManager__invoke_handler_by_type_sync( + RedisHelper.handle_config_changed, + _build_event({"CACHE_BACKEND_URL"}), + ) + monkeypatch.setitem(Singleton._instances, singleton_key, second) + eventmanager._EventManager__invoke_handler_by_type_sync( + RedisHelper.handle_config_changed, + _build_event({"CACHE_BACKEND_URL"}), + ) + + first_reload.assert_called_once_with() + second_reload.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_config_reload_resolver_invokes_current_async_redis_owner(monkeypatch): + """异步配置处理器必须绑定当前 Redis owner,并保留键筛选。""" + helper = object.__new__(AsyncRedisHelper) + reload_config = AsyncMock() + helper.on_config_changed = reload_config + singleton_key = (AsyncRedisHelper, (), frozenset()) + monkeypatch.setitem(Singleton._instances, singleton_key, helper) + monkeypatch.setattr( + eventmanager, + "_EventManager__handler_instance_resolvers", + {}, + ) + _configure_lifespan_resolvers() + + await eventmanager._EventManager__invoke_handler_by_type_async( + AsyncRedisHelper.handle_config_changed, + _build_event({"CACHE_REDIS_MAX_CONNECTIONS"}), + ) + await eventmanager._EventManager__invoke_handler_by_type_async( + AsyncRedisHelper.handle_config_changed, + _build_event({"OTHER_KEY"}), + ) + + reload_config.assert_awaited_once_with() + + +def test_config_reload_resolver_does_not_materialize_lazy_transfer_owner( + monkeypatch, +): + """尚未创建的整理链只声明跳过,配置事件不得启动线程资源。""" + singleton_key = (TransferChain, (), frozenset()) + monkeypatch.delitem(Singleton._instances, singleton_key, raising=False) + monkeypatch.setattr( + eventmanager, + "_EventManager__handler_instance_resolvers", + {}, + ) + _configure_lifespan_resolvers() + + eventmanager._EventManager__invoke_handler_by_type_sync( + TransferChain.handle_config_changed, + _build_event({"TRANSFER_THREADS"}), + ) + + assert TransferChain.get_existing_instance() is None + + +def test_plugin_manager_falls_through_plugin_resolver_to_current_owner( + monkeypatch, +): + """resolver 先注册时也应在插件 Runtime 创建后接管当前管理器。""" + singleton_key = (PluginManager, (), frozenset()) + monkeypatch.delitem(Singleton._instances, singleton_key, raising=False) + monkeypatch.setattr( + eventmanager, + "_EventManager__handler_instance_resolvers", + {}, + ) + configure_config_reload_event_handler_resolver() + + manager = object.__new__(PluginManager) + reload_config = Mock() + manager.on_config_changed = reload_config + monkeypatch.setitem(Singleton._instances, singleton_key, manager) + plugin_resolver = Mock(return_value=None) + eventmanager.register_handler_instance_resolver( + "plugins", + plugin_resolver, + ) + + eventmanager._EventManager__invoke_handler_by_type_sync( + PluginManager.handle_config_changed, + _build_event({"PLUGIN_AUTO_RELOAD"}), + ) + + plugin_resolver.assert_not_called() + reload_config.assert_called_once_with() + + +def test_config_reload_resolver_registration_replaces_same_name(monkeypatch): + """重复装配只替换当前 lifespan resolver,不累积并行绑定。""" + monkeypatch.setattr( + eventmanager, + "_EventManager__handler_instance_resolvers", + {}, + ) + + configure_config_reload_event_handler_resolver() + first = eventmanager._EventManager__handler_instance_resolvers[ + "config_reload" + ] + configure_config_reload_event_handler_resolver() + + assert set(eventmanager._EventManager__handler_instance_resolvers) == { + "config_reload" + } + assert ( + eventmanager._EventManager__handler_instance_resolvers[ + "config_reload" + ] + is not first + ) + + +@pytest.mark.parametrize( + ("owner_class", "registry", "singleton_key"), + [ + ( + PluginManager, + Singleton._instances, + (PluginManager, (), frozenset()), + ), + (Monitor, SingletonClass._instances, Monitor), + ], +) +def test_config_reload_provider_returns_lifecycle_owned_instance( + monkeypatch, + owner_class, + registry, + singleton_key, +): + """管理器和监控器必须从各自生命周期注册表读取当前实例。""" + instance = object.__new__(owner_class) + monkeypatch.setitem(registry, singleton_key, instance) + + providers = get_config_reload_handler_providers() + + assert providers[owner_class]() is instance + + +def test_system_reload_provider_keeps_one_lifespan_instance() -> None: + """无资源的系统配置处理器也由 resolver 闭包保持稳定 owner 身份。""" + provider = get_config_reload_handler_providers()[SystemHelper] + + assert provider() is provider() diff --git a/tests/test_event_runtime_components.py b/tests/test_event_runtime_components.py index eb9f67e12..cdfde236b 100644 --- a/tests/test_event_runtime_components.py +++ b/tests/test_event_runtime_components.py @@ -5,14 +5,20 @@ import threading import types from unittest.mock import Mock +from app.foundation.singleton import Singleton from app.runtime.event.binding import ( EventBindingResolver, EventHandlerBinding, ) from app.runtime.event.errors import EventErrorPolicy from app.runtime.events import Event +from app.runtime.extensions.plugin import manager as plugin_manager_module +from app.runtime.extensions.plugin.manager import PluginManager from app.schemas.types import EventType -from app.startup.initializers.modules import get_host_event_handler_factories +from app.startup.initializers.modules import ( + get_config_reload_handler_providers, + get_host_event_handler_factories, +) class _UnmanagedHandler: @@ -28,6 +34,15 @@ class _UnmanagedHandler: """提供可解析的实例方法声明。""" +class _PluginHandler: + """代表插件运行时登记的事件 owner。""" + + @staticmethod + def get_name() -> str: + """返回测试插件名称。""" + return "测试插件" + + def _free_function_handler(_event: Event) -> None: """模块级自由函数处理器,用于验证直调路径保持不变。""" @@ -155,6 +170,41 @@ def test_binding_uses_explicit_resolver_instance() -> None: assert method_name == "handle" +def test_plugin_resolver_requires_exact_registered_class() -> None: + """同名宿主类不能被 plugins resolver 误识别为插件 owner。""" + manager = object.__new__(PluginManager) + plugin = _PluginHandler() + manager._plugins = {_PluginHandler.__name__: _PluginHandler} + manager._running_plugins = {_PluginHandler.__name__: plugin} + impostor = type(_PluginHandler.__name__, (), {}) + + assert manager.resolve_event_handler_instance( + _PluginHandler + ) == EventHandlerBinding( + instance=plugin, + owner_name="测试插件", + run_sync_in_threadpool=True, + ) + assert manager.resolve_event_handler_instance(impostor) is None + + +def test_plugin_resolver_does_not_materialize_manager(monkeypatch) -> None: + """残留 resolver 在管理器不存在时必须跳过,不能构造插件 Runtime。""" + singleton_key = (PluginManager, (), frozenset()) + monkeypatch.delitem(Singleton._instances, singleton_key, raising=False) + runtime_factory = Mock(side_effect=AssertionError("不得构造 PluginManager")) + monkeypatch.setattr( + plugin_manager_module, + "_plugin_runtime_factory", + runtime_factory, + ) + + assert plugin_manager_module._resolve_plugin_handler_instance( + _PluginHandler + ) is None + runtime_factory.assert_not_called() + + def test_system_error_failure_does_not_rebroadcast() -> None: """SystemError 处理器自身失败时只能通知和日志降级,不能再次发送事件。""" notifier = Mock() @@ -213,3 +263,28 @@ def test_all_decorated_host_handler_classes_have_explicit_factories() -> None: "SubscribeChain", "WorkflowChain", } + + +def test_all_unmanaged_config_reload_classes_have_explicit_providers( + monkeypatch, +) -> None: + """专属 resolver 未覆盖的配置 owner 必须全部声明生命周期 Provider。""" + manager = object.__new__(PluginManager) + plugin_singleton_key = (PluginManager, (), frozenset()) + monkeypatch.setitem(Singleton._instances, plugin_singleton_key, manager) + doh_helper = object.__new__( + __import__("app.adapters.network.doh", fromlist=["DohHelper"]).DohHelper + ) + doh_singleton_key = (type(doh_helper), (), frozenset()) + monkeypatch.setitem(Singleton._instances, doh_singleton_key, doh_helper) + providers = get_config_reload_handler_providers() + + assert {owner.__name__ for owner in providers} == { + "AsyncRedisHelper", + "DohHelper", + "Monitor", + "PluginManager", + "RedisHelper", + "SystemHelper", + "TransferChain", + } diff --git a/tests/test_residual_provider_resets.py b/tests/test_residual_provider_resets.py index 069b15d85..762b24b7e 100644 --- a/tests/test_residual_provider_resets.py +++ b/tests/test_residual_provider_resets.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections.abc import Iterator from types import SimpleNamespace from typing import Any +from unittest.mock import Mock import pytest @@ -19,15 +20,19 @@ from app.application.database import ( get_database_governance, reset_database_governance, ) +from app.foundation.singleton import Singleton from app.runtime import resources as managed_resource_facade -from app.runtime.events import EventManager +from app.runtime.events import Event, EventHandlerBinding, EventManager from app.runtime.resources import ( configure_managed_resource_runtime, reset_managed_resource_runtime, ) +from app.runtime.state import SystemHelper from app.scheduler.facade import Scheduler +from app.schemas.event import ConfigChangeEventData from app.schemas.types import EventType from app.startup.composition import resource as managed_resource_composition +from app.startup.initializers.modules import reset_event_services _MISSING = object() @@ -180,6 +185,55 @@ def test_event_manager_host_binding_reset_contract() -> None: assert second_resolver is not first_resolver +def test_reset_event_services_unregisters_lifespan_resolvers(monkeypatch) -> None: + """模块 lifespan 结束时应撤销宿主与配置 owner resolver。""" + manager = object.__new__(EventManager) + EventManager.__init__(manager) + singleton_key = (EventManager, (), frozenset()) + monkeypatch.setitem(Singleton._instances, singleton_key, manager) + owner = object.__new__(SystemHelper) + reload_config = Mock() + owner.on_config_changed = reload_config + + def plugin_resolver(_owner: type) -> None: + """代表插件运行时独立拥有的 resolver。""" + + def config_resolver(owner_class: type) -> EventHandlerBinding | None: + """代表当前 lifespan 持有的配置 owner resolver。""" + if owner_class is SystemHelper: + return EventHandlerBinding( + instance=owner, + owner_name=owner_class.__name__, + ) + return None + + manager.register_handler_instance_resolver("plugins", plugin_resolver) + manager.register_handler_instance_resolver("host", lambda _owner: None) + manager.register_handler_instance_resolver( + "config_reload", + config_resolver, + ) + event = Event( + EventType.ConfigChanged, + ConfigChangeEventData(key={"DEBUG"}), + ) + manager._EventManager__invoke_handler_by_type_sync( + SystemHelper.handle_config_changed, + event, + ) + + reset_event_services() + manager._EventManager__invoke_handler_by_type_sync( + SystemHelper.handle_config_changed, + event, + ) + + assert manager._EventManager__handler_instance_resolvers == { + "plugins": plugin_resolver, + } + reload_config.assert_called_once_with() + + def test_managed_resource_owner_reset_releases_only_closed_runtime( monkeypatch: pytest.MonkeyPatch, ) -> None: