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(