mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-09 01:16:50 +08:00
修复非模块组件配置变更未触发热重载 (#6569)
* fix(event): bind config reload lifecycle owners * chore(architecture): update config reload dependency baseline * fix(event): preserve config resolver type contracts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
# 启动事件消费
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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 等基线均为零 |
|
||||
|
||||
+7
-2
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user