From 282b922f1b3dfa6ad68b78a4d456698bea37da02 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Tue, 18 Aug 2026 21:40:13 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E9=87=8D=E8=BD=BD=E7=AA=97=E5=8F=A3=E6=9C=9F?= =?UTF-8?q?=E6=AE=8B=E7=95=99=20handler=20=E7=9B=B4=E8=B0=83=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=20missing=20event=20TypeError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 插件重载 stop 阶段清除 sys.modules 缓存后,若广播事件命中注册表中残留的旧类方法声明, EventBindingResolver.owner_class 因 inspect.getmodule 返回 None 而解析失败,原实现会 直接返回原始函数并绕过实例绑定,旧签名无 event 参数时抛出 TypeError 并刷屏通知。 - binding.py:owner_class 解析失败时区分类方法声明与自由函数;类方法声明记入 unresolved 诊断并跳过执行,等待重载完成后新 handler 按标识覆盖自愈; 自由函数保持直调路径不变。 - 收敛 binding-miss 与新路径的未命中记账逻辑到 _record_unresolved,首次未命中才告警。 - 新增回归测试:模块卸载后残留类方法被跳过、自由函数直调不受影响。 Closes #6351 --- app/runtime/event/binding.py | 36 ++++++++++++----- tests/test_event_runtime_components.py | 53 ++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/app/runtime/event/binding.py b/app/runtime/event/binding.py index a5ff91fc7..d80026b68 100644 --- a/app/runtime/event/binding.py +++ b/app/runtime/event/binding.py @@ -57,6 +57,12 @@ class EventBindingResolver: return "", names[0] return names[0], names[1] + @staticmethod + def is_class_method_declaration(handler: Callable) -> bool: + """判断处理器是否声明在类体内(限定名含类前缀且非局部闭包)。""" + parts = handler.__qualname__.split(".") + return len(parts) >= 2 and "" not in parts + @staticmethod def owner_class(handler: Callable) -> Optional[Type[Any]]: """从处理器对象本身解析声明类,不按字符串动态导入模块。""" @@ -75,6 +81,14 @@ class EventBindingResolver: return None return owner if isinstance(owner, type) else None + def _record_unresolved(self, identifier: str, reason: str) -> None: + """首次未命中时记录告警,避免重载窗口内重复刷屏。""" + with self._lock: + first_miss = identifier not in self._unresolved + self._unresolved.add(identifier) + if first_miss: + logger.warning(reason, identifier) + def resolve( self, handler: Callable, @@ -87,6 +101,15 @@ class EventBindingResolver: self.parse_handler_names(handler)[1], ) if owner_class is None: + # 插件重载会先清除模块缓存,窗口期内残留的旧类方法声明无法定位声明类; + # 此时直接调用原始函数会绕过实例绑定(旧签名可能与事件调用约定不一致), + # 因此按未绑定处理跳过,等待重载完成后按新 handler 注册自愈。 + if self.is_class_method_declaration(handler): + self._record_unresolved( + EventRegistry.handler_identifier(handler), + "事件处理器所属模块已卸载或声明类不可解析,跳过执行:%s", + ) + return None binding = EventHandlerBinding( instance=None, owner_name=EventRegistry.handler_identifier(handler), @@ -105,15 +128,10 @@ class EventBindingResolver: resolver_name = name break if binding is None: - identifier = EventRegistry.handler_identifier(handler) - with self._lock: - first_miss = identifier not in self._unresolved - self._unresolved.add(identifier) - if first_miss: - logger.warning( - "事件处理器未绑定显式 resolver,已跳过:%s", - identifier, - ) + self._record_unresolved( + EventRegistry.handler_identifier(handler), + "事件处理器未绑定显式 resolver,已跳过:%s", + ) return None logger.debug( "事件处理器绑定:%s -> %s", diff --git a/tests/test_event_runtime_components.py b/tests/test_event_runtime_components.py index 72c47195a..ea087437a 100644 --- a/tests/test_event_runtime_components.py +++ b/tests/test_event_runtime_components.py @@ -1,6 +1,8 @@ """事件注册、绑定、调度和错误策略组件的独立测试。""" +import sys import threading +import types from unittest.mock import Mock from app.runtime.event.binding import ( @@ -26,6 +28,10 @@ class _UnmanagedHandler: """提供可解析的实例方法声明。""" +def _free_function_handler(_event: Event) -> None: + """模块级自由函数处理器,用于验证直调路径保持不变。""" + + def test_binding_miss_does_not_construct_handler_owner() -> None: """resolver 未命中时只记录诊断,不能调用 owner_class()。""" resolvers = {} @@ -42,6 +48,53 @@ def test_binding_miss_does_not_construct_handler_owner() -> None: ) +def test_unloaded_module_class_handler_is_skipped() -> None: + """模块缓存被清除后,残留的类方法声明必须跳过而非直调原始函数。""" + fake_name = "tests._fake_unloaded_plugin" + fake_module = types.ModuleType(fake_name) + sys.modules[fake_name] = fake_module + try: + # 在伪模块命名空间内构造类,使处理器 __module__ 指向该模块 + exec( + "class _ResidualPlugin:\n" + " def reload(self, event):\n" + " raise AssertionError('residual handler must not run')\n", + fake_module.__dict__, + ) + residual_handler = fake_module._ResidualPlugin.reload + # 模拟插件重载 stop 阶段清除模块缓存后的残留注册 + del sys.modules[fake_name] + + binding = EventBindingResolver( + lock=threading.Lock(), + resolvers=lambda: {}, + ) + assert binding.resolve(residual_handler) is None + # 模块卸载后 identifier 回退为 unknown_module 前缀,与线上日志一致 + assert binding.unresolved_handlers() == ( + "unknown_module._ResidualPlugin.reload", + ) + finally: + sys.modules.pop(fake_name, None) + + +def test_free_function_handler_still_invoked_directly() -> None: + """自由函数处理器不属于类声明,保持直调路径不被新跳过逻辑影响。""" + binding = EventBindingResolver( + lock=threading.Lock(), + resolvers=lambda: {}, + ) + + resolved = binding.resolve(_free_function_handler) + + assert resolved is not None + method, handler_binding, class_name, method_name = resolved + assert method is _free_function_handler + assert handler_binding.run_sync_in_threadpool is True + assert class_name == "" + assert method_name == "_free_function_handler" + + def test_binding_uses_explicit_resolver_instance() -> None: """显式 resolver 应返回当前托管实例上的绑定方法。""" instance = object.__new__(_UnmanagedHandler)