From 8e4a49e958eaa1701f6751c61c14f0da4e80c762 Mon Sep 17 00:00:00 2001 From: Aqr-K <95741669+Aqr-K@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:05:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(plugin):=20=E4=BF=AE=E5=A4=8D=E5=88=86?= =?UTF-8?q?=E8=BA=AB=E4=B8=8E=E6=9C=AC=E4=BD=93=E5=9C=A8=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E5=AE=9A=E5=90=91=E5=92=8C=E6=B3=A8=E5=86=8C=E8=A1=A8=E9=94=AE?= =?UTF-8?q?=E4=B8=8A=E7=9A=84=E8=BA=AB=E4=BB=BD=E6=B7=B7=E6=B7=86=20(#6672?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/runtime/event/dispatch.py | 20 +++- app/runtime/extensions/plugin/loader.py | 36 +++++- tests/test_plugin_clone_event_targeting.py | 125 +++++++++++++++++++++ tests/test_plugin_virtual_instances.py | 114 ++++++++++++++++++- 4 files changed, 285 insertions(+), 10 deletions(-) create mode 100644 tests/test_plugin_clone_event_targeting.py diff --git a/app/runtime/event/dispatch.py b/app/runtime/event/dispatch.py index 0fb0f00f4..8f5563dfb 100644 --- a/app/runtime/event/dispatch.py +++ b/app/runtime/event/dispatch.py @@ -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: diff --git a/app/runtime/extensions/plugin/loader.py b/app/runtime/extensions/plugin/loader.py index 282fe7d6e..352ee8ba3 100644 --- a/app/runtime/extensions/plugin/loader.py +++ b/app/runtime/extensions/plugin/loader.py @@ -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: diff --git a/tests/test_plugin_clone_event_targeting.py b/tests/test_plugin_clone_event_targeting.py new file mode 100644 index 000000000..2ff1e36fd --- /dev/null +++ b/tests/test_plugin_clone_event_targeting.py @@ -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 diff --git a/tests/test_plugin_virtual_instances.py b/tests/test_plugin_virtual_instances.py index ae7d2ccfd..480df3d74 100644 --- a/tests/test_plugin_virtual_instances.py +++ b/tests/test_plugin_virtual_instances.py @@ -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,