From 27ae1b52903036b0819574dcaa9cef77fbdf06ed Mon Sep 17 00:00:00 2001 From: jxxghp Date: Thu, 20 Aug 2026 08:51:22 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=8F=92=E4=BB=B6=E9=87=8D=E8=BD=BD?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E6=9C=9F=E8=A3=85=E9=A5=B0=E5=99=A8=E5=8C=85?= =?UTF-8?q?=E8=A3=85=E7=B1=BB=E6=96=B9=E6=B3=95=E8=AF=AF=E5=88=A4=E7=9B=B4?= =?UTF-8?q?=E8=B0=83=E5=AF=BC=E8=87=B4=20missing=20event=20TypeError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 事件绑定解析在 owner_class 为 None 时仅靠限定名区分自由函数与类方法声明, 装饰器包装的方法限定名含 被误判为自由函数 unbound 直调,把 event 吞进 self 触发 TypeError。局部作用域限定名无法区分包装方法与局部自由函数, 按调用约定兜底:签名首参为 self/cls 才视为类方法声明跳过执行。 --- app/runtime/event/binding.py | 22 +++++++++++++++-- tests/test_event_runtime_components.py | 34 ++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/app/runtime/event/binding.py b/app/runtime/event/binding.py index d80026b68..29b492dbe 100644 --- a/app/runtime/event/binding.py +++ b/app/runtime/event/binding.py @@ -59,9 +59,27 @@ class EventBindingResolver: @staticmethod def is_class_method_declaration(handler: Callable) -> bool: - """判断处理器是否声明在类体内(限定名含类前缀且非局部闭包)。""" + """判断处理器是否声明在类体内(限定名含类前缀或签名首参为 self/cls)。 + + 模块级顶层自由函数的限定名不含 ``.``;类方法、装饰器包装和嵌套函数 + 的限定名含 ``.``。局部作用域自由函数(如测试内联 handler)限定名形如 + ``func..handler``,与装饰器包装方法 ``SiteStatistic.. + wrapper`` 无法靠限定名区分,需按调用约定兜底:签名首参为 self/cls + 才视为类方法声明。模块卸载后残留的类方法一旦被 unbound 直调,会把 + event 吞进 self 触发 missing event TypeError,因此必须跳过等待重载自愈。 + """ parts = handler.__qualname__.split(".") - return len(parts) >= 2 and "" not in parts + if len(parts) < 2: + return False + if "" not in parts: + return True + try: + parameters = list(inspect.signature(handler).parameters.values()) + except (TypeError, ValueError): + return True + if not parameters: + return True + return parameters[0].name in ("self", "cls") @staticmethod def owner_class(handler: Callable) -> Optional[Type[Any]]: diff --git a/tests/test_event_runtime_components.py b/tests/test_event_runtime_components.py index ea087437a..750f95f1a 100644 --- a/tests/test_event_runtime_components.py +++ b/tests/test_event_runtime_components.py @@ -78,6 +78,40 @@ def test_unloaded_module_class_handler_is_skipped() -> None: sys.modules.pop(fake_name, None) +def test_unloaded_module_decorator_wrapped_method_is_skipped() -> None: + """装饰器包装的类方法限定名含 ,模块卸载后也必须跳过而非直调。""" + fake_name = "tests._fake_unloaded_decorated_plugin" + fake_module = types.ModuleType(fake_name) + sys.modules[fake_name] = fake_module + try: + exec( + "def _deco(f):\n" + " def wrapper(self, event):\n" + " return f(self, event)\n" + " return wrapper\n" + "class _DecoratedPlugin:\n" + " @_deco\n" + " def send_msg(self, event):\n" + " raise AssertionError('residual handler must not run')\n", + fake_module.__dict__, + ) + residual_handler = fake_module._DecoratedPlugin.send_msg + # 装饰器包装后限定名含 ,不能因此被误判为自由函数 + assert "" in residual_handler.__qualname__ + del sys.modules[fake_name] + + binding = EventBindingResolver( + lock=threading.Lock(), + resolvers=lambda: {}, + ) + assert binding.resolve(residual_handler) is None + assert binding.unresolved_handlers() == ( + "unknown_module._deco..wrapper", + ) + finally: + sys.modules.pop(fake_name, None) + + def test_free_function_handler_still_invoked_directly() -> None: """自由函数处理器不属于类声明,保持直调路径不被新跳过逻辑影响。""" binding = EventBindingResolver(