fix(plugin): 修复分身与本体在事件定向和注册表键上的身份混淆 (#6672)

This commit is contained in:
Aqr-K
2026-09-13 21:05:26 +08:00
committed by GitHub
parent 5fd04505c1
commit 8e4a49e958
4 changed files with 285 additions and 10 deletions

View File

@@ -372,12 +372,24 @@ class EventDispatcher:
handler_identifier: str,
target_plugin_id: str,
) -> bool:
"""只把定向输入事件投递给标识和声明均匹配的目标插件。"""
"""只把定向输入事件投递给标识和声明均匹配的目标插件。
目标匹配按运行实例身份判断,不能只看处理器的限定名:分身共享源码,其
``__qualname__`` 保持源类名不变,只比类名会让定向到分身的事件全部落空,
而定向到本体的事件被本体连同它的全部分身一起收到。实例身份编码在处理器
标识的模块段里(``app.plugins.<实例ID>``);不是插件处理器时回落到类名比较,
保持宿主侧处理器的既有行为。
"""
class_name, method_name = EventBindingResolver.parse_handler_names(handler)
if class_name != target_plugin_id:
return False
parts = (handler_identifier or "").split(".")
return len(parts) >= 2 and parts[-2:] == [class_name, method_name]
if len(parts) < 2 or parts[-2:] != [class_name, method_name]:
return False
module_path = ".".join(parts[:-2])
prefix = "app.plugins."
if module_path.startswith(prefix):
owner_id = module_path[len(prefix):].split(".")[0]
return owner_id.casefold() == target_plugin_id.casefold()
return class_name == target_plugin_id
@staticmethod
def _log_lifecycle(event: Any, stage: str) -> None:

View File

@@ -268,11 +268,37 @@ class PluginLoader:
if spec and getattr(spec, "name", None) == source_name:
spec.name = instance_name
for value in vars(module).values():
if getattr(value, "__module__", None) == source_name:
try:
value.__module__ = instance_name
except (AttributeError, TypeError):
continue
if getattr(value, "__module__", None) != source_name:
continue
try:
value.__module__ = instance_name
except (AttributeError, TypeError):
continue
if isinstance(value, type):
PluginLoader._retarget_member_identity(
value, source_name, instance_name
)
@staticmethod
def _retarget_member_identity(
owner: type,
source_name: str,
instance_name: str,
) -> None:
"""把类体内定义的函数一并改到实例模块名下。
事件处理器的注册键取自函数所在模块名,而类体内的函数是类的属性、不是模块
的属性,只遍历模块顶层碰不到它们。漏改会让实例与源插件本体注册到同一个
handler 键上:后注册的覆盖先注册的,实例收不到事件,停本体会把实例一起停掉。
"""
for member in vars(owner).values():
target = getattr(member, "__func__", member)
if getattr(target, "__module__", None) != source_name:
continue
try:
target.__module__ = instance_name
except (AttributeError, TypeError):
continue
@staticmethod
def _adapt_instance_class(candidate: Any, instance: PluginInstance) -> None:

View File

@@ -0,0 +1,125 @@
"""定向输入事件在源插件本体与其分身之间的投递归属测试。"""
import sys
from types import ModuleType
from app.runtime.event.dispatch import EventDispatcher
from app.runtime.event.registry import EventRegistry
def _install_plugin_handler(
monkeypatch,
*,
module_name: str,
declared_class_name: str,
runtime_class_name: str,
):
"""在指定模块命名空间下造出一个插件事件处理器,并返回其注册标识。
复刻装载器造出的运行事实:分身与本体共享源码,因此处理器的 ``__qualname__``
恒为源类名;区别只在分身被放进自己的模块命名空间、并被改写了类的 ``__name__``。
:param monkeypatch: 用于把假模块登记进 ``sys.modules`` 并在用例结束后还原
:param module_name: 该实例的模块名
:param declared_class_name: 源码里声明的类名,即处理器限定名的前缀
:param runtime_class_name: 运行身份的类名,分身为其实例 ID
:return: 处理器函数与其注册标识
"""
module = ModuleType(module_name)
def handle_message_action(self, event):
"""占位的定向输入事件处理器。"""
return self, event
handle_message_action.__module__ = module_name
handle_message_action.__qualname__ = f"{declared_class_name}.handle_message_action"
plugin_class = type(
declared_class_name,
(),
{"handle_message_action": handle_message_action},
)
plugin_class.__module__ = module_name
plugin_class.__name__ = runtime_class_name
setattr(module, declared_class_name, plugin_class)
monkeypatch.setitem(sys.modules, module_name, module)
handler = plugin_class.handle_message_action
return handler, EventRegistry.handler_identifier(handler)
def test_targeted_event_reaches_the_clone_that_opened_the_input_session(monkeypatch):
"""定向到分身的输入事件必须投递给该分身自己的处理器。
分身共享源码,处理器限定名里的类名始终是源类名,只比类名会让「目标是分身」
这一判断恒不成立,用户在分身里发起的输入会话随后收不到任何回复。
"""
handler, handler_id = _install_plugin_handler(
monkeypatch,
module_name="app.plugins.demopluginwork",
declared_class_name="DemoPlugin",
runtime_class_name="DemoPluginWork",
)
assert handler_id == "app.plugins.demopluginwork.DemoPlugin.handle_message_action"
assert EventDispatcher.should_dispatch_to_target_plugin(
handler,
handler_id,
"DemoPluginWork",
) is True
def test_targeted_event_for_the_host_is_not_broadcast_to_its_clones(monkeypatch):
"""定向到本体的输入事件不得同时落进它的分身。
分身与本体的处理器限定名完全一致,只比类名会让本体的定向事件被全部分身一起
收到——定向投递本来就是为了不让自由文本被别的实例看到。
"""
host_handler, host_id = _install_plugin_handler(
monkeypatch,
module_name="app.plugins.demoplugin",
declared_class_name="DemoPlugin",
runtime_class_name="DemoPlugin",
)
clone_handler, clone_id = _install_plugin_handler(
monkeypatch,
module_name="app.plugins.demopluginwork",
declared_class_name="DemoPlugin",
runtime_class_name="DemoPluginWork",
)
assert EventDispatcher.should_dispatch_to_target_plugin(
host_handler,
host_id,
"DemoPlugin",
) is True
assert EventDispatcher.should_dispatch_to_target_plugin(
clone_handler,
clone_id,
"DemoPlugin",
) is False
assert EventDispatcher.should_dispatch_to_target_plugin(
host_handler,
host_id,
"DemoPluginWork",
) is False
def test_non_plugin_handlers_keep_matching_by_declared_class_name(monkeypatch):
"""宿主侧处理器不在插件命名空间里,仍按声明类名匹配。"""
handler, handler_id = _install_plugin_handler(
monkeypatch,
module_name="tests.hosted.demo_plugin",
declared_class_name="DemoPlugin",
runtime_class_name="DemoPlugin",
)
assert EventDispatcher.should_dispatch_to_target_plugin(
handler,
handler_id,
"DemoPlugin",
) is True
assert EventDispatcher.should_dispatch_to_target_plugin(
handler,
handler_id,
"OtherPlugin",
) is False

View File

@@ -1,10 +1,14 @@
"""虚拟插件实例的持久化、加载和创建行为测试。"""
import importlib.util
import sys
import threading
from pathlib import Path
from types import ModuleType, SimpleNamespace
import pytest
from app.runtime.event.registry import EventRegistry
from app.runtime.extensions.plugin.clone import PluginCloneService
from app.runtime.extensions.plugin.loader import PluginLoader
from app.runtime.extensions.plugin.storage import (
@@ -13,7 +17,7 @@ from app.runtime.extensions.plugin.storage import (
PluginStorage,
)
from app.schemas.plugin import PluginInstance, PluginRuntimeStatus
from app.schemas.types import SystemConfigKey
from app.schemas.types import EventType, SystemConfigKey
def _make_directory() -> tuple[PluginInstanceDirectory, dict[str, PluginInstance]]:
@@ -503,6 +507,114 @@ def test_loader_executes_each_instance_in_an_isolated_module_namespace(
assert plugin_package.demoplugin is source_module
def _import_host_package(module_name: str, source_dir: Path) -> ModuleType:
"""按源插件本体的模块名导入磁盘源码,复刻本体已被装载的运行事实。
本体必须真的在 ``sys.modules`` 里:处理器的注册键要靠函数的 ``__module__``
反查模块对象才能算出来,本体缺席会让两边都算成同一个占位名,掩盖分身与本体
撞键这件事本身。
"""
spec = importlib.util.spec_from_file_location(
module_name,
source_dir / "__init__.py",
submodule_search_locations=[str(source_dir)],
)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
def _forget_modules(*prefixes: str) -> None:
"""清除用例在模块缓存里留下的插件模块,避免污染后续用例。"""
for name in list(sys.modules):
if any(name == prefix or name.startswith(f"{prefix}.") for prefix in prefixes):
sys.modules.pop(name, None)
def test_loader_gives_submodule_handlers_of_each_instance_distinct_registry_keys(
tmp_path,
monkeypatch,
):
"""分身在子模块类体内声明的处理器,注册键必须与本体的区分开。
事件处理器的注册键取自函数所在模块名。类体内的函数是类的属性而不是模块的
属性,只遍历模块顶层碰不到它们;漏改会让分身与本体注册到同一个键上,后注册
的顶掉先注册的:分身收不到事件,停掉本体会把分身一起停掉。
"""
source_dir = tmp_path / "identityplugin"
source_dir.mkdir()
(source_dir / "core.py").write_text(
"class IdentityHandlers:\n"
" def on_event(self, event):\n"
" return event\n"
"\n"
" @staticmethod\n"
" def on_static_event(event):\n"
" return event\n",
encoding="utf-8",
)
(source_dir / "__init__.py").write_text(
"from app.plugins.identityplugin.core import IdentityHandlers\n"
"\n"
"\n"
"class IdentityPlugin(IdentityHandlers):\n"
" plugin_name = 'Identity'\n"
"\n"
" def init_plugin(self, _config):\n"
" return None\n",
encoding="utf-8",
)
import app.plugins as plugin_package
loader = PluginLoader(
plugins_root=tmp_path,
import_preparer=lambda **_kwargs: None,
import_scanner=lambda **_kwargs: None,
log=_logger(),
)
try:
host_module = _import_host_package("app.plugins.identityplugin", source_dir)
monkeypatch.setattr(
plugin_package, "identityplugin", host_module, raising=False
)
clone_class = loader.load_instance(
PluginInstance(
instance_id="IdentityPluginWork",
source_plugin_id="IdentityPlugin",
),
lambda candidate: hasattr(candidate, "init_plugin"),
)[0]
host_handler = host_module.IdentityPlugin.on_event
clone_handler = clone_class.on_event
subscribers: dict = {}
registry = EventRegistry(
lock=threading.RLock(),
broadcast_subscribers=lambda: subscribers,
chain_subscribers=dict,
disabled_handlers=set,
disabled_classes=set,
)
registry.add(EventType.PluginAction, host_handler, 0)
registry.add(EventType.PluginAction, clone_handler, 0)
assert len(subscribers[EventType.PluginAction]) == 2
assert EventRegistry.handler_identifier(host_handler) == (
"app.plugins.identityplugin.core.IdentityHandlers.on_event"
)
assert EventRegistry.handler_identifier(clone_handler) == (
"app.plugins.identitypluginwork.core.IdentityHandlers.on_event"
)
assert EventRegistry.handler_identifier(clone_class.on_static_event) == (
"app.plugins.identitypluginwork.core.IdentityHandlers.on_static_event"
)
finally:
_forget_modules(
"app.plugins.identityplugin",
"app.plugins.identitypluginwork",
)
def test_loader_runtime_gate_only_rejects_explicit_incompatible_declarations(
tmp_path,
monkeypatch,