From 4858f4652b78359ede69e0f25830b15e05c94d4b Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 15 Aug 2026 21:27:59 +0800 Subject: [PATCH] =?UTF-8?q?fix(runtime):=20=E4=BF=AE=E5=A4=8D=20ConfigRelo?= =?UTF-8?q?adMixin=20=E4=BA=8B=E4=BB=B6=E5=A4=84=E7=90=86=E5=99=A8?= =?UTF-8?q?=E5=9B=A0=20=5F=5Fname=5F=5F=20=E6=9C=AA=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E8=A2=AB=E9=9D=99=E9=BB=98=E8=B7=B3=E8=BF=87=EF=BC=8C=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=8F=98=E6=9B=B4=E4=B8=8D=E5=86=8D=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E9=87=8D=E8=BD=BD=20(#6329)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/runtime/events.py | 12 ++- app/runtime/reload.py | 3 + tests/test_config_reload_handler.py | 128 ++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 tests/test_config_reload_handler.py diff --git a/app/runtime/events.py b/app/runtime/events.py index e7b494297..aea2174d6 100644 --- a/app/runtime/events.py +++ b/app/runtime/events.py @@ -675,7 +675,17 @@ class EventManager(metaclass=Singleton): return None method = getattr(binding.instance, method_name, None) if not callable(method): - return None + # 动态生成的处理器可能只同步了 __qualname__,__name__ 与类上方法名不一致时 + # 回退到限定名末段重试;仍无法解析时记录告警,避免静默跳过 + fallback_name = self.__parse_handler_names(handler)[1] + method = getattr(binding.instance, fallback_name, None) + if fallback_name == method_name or not callable(method): + logger.warning( + f"事件处理器 {self.__get_handler_identifier(handler)} " + f"无法解析为实例方法 {owner_class.__name__}.{method_name},跳过执行" + ) + return None + method_name = fallback_name return method, binding, owner_class.__name__, method_name def __broadcast_consumer_loop(self): diff --git a/app/runtime/reload.py b/app/runtime/reload.py index 67e58c1af..8d4bc6948 100644 --- a/app/runtime/reload.py +++ b/app/runtime/reload.py @@ -66,6 +66,9 @@ class ConfigReloadMixin: # 创建并设置处理函数 handler = create_handler(is_async) handler.__module__ = cls.__module__ + # 事件总线按 __name__ 在实例上解析方法,必须与类上的方法名保持一致, + # 否则闭包默认名 wrapper 会导致处理器被静默跳过 + handler.__name__ = method_name handler.__qualname__ = f"{cls.__name__}.{method_name}" setattr(cls, method_name, handler) # 添加为事件处理器 diff --git a/tests/test_config_reload_handler.py b/tests/test_config_reload_handler.py new file mode 100644 index 000000000..3d1367a34 --- /dev/null +++ b/tests/test_config_reload_handler.py @@ -0,0 +1,128 @@ +"""ConfigReloadMixin 配置变更事件处理器解析的回归测试(Issue #6329)。 + +ConfigReloadMixin 动态生成的事件处理器,必须能被事件总线解析回实例方法, +不能因 __name__ 与类上方法名不一致而被静默跳过。 +""" + +import inspect + +import pytest + +from app.runtime.events import Event, EventHandlerBinding, eventmanager +from app.runtime.reload import ConfigReloadMixin +from app.schemas import ConfigChangeEventData +from app.schemas.types import EventType + + +class _ReloadRecorder(ConfigReloadMixin): + """测试用同步重载子类,记录重载调用次数。""" + + CONFIG_WATCH = {"TEST_RELOAD_KEY"} + + def __init__(self): + self.reload_count = 0 + + def on_config_changed(self): + self.reload_count += 1 + + +class _AsyncReloadRecorder(ConfigReloadMixin): + """测试用异步重载子类,记录重载调用次数。""" + + CONFIG_WATCH = {"TEST_RELOAD_KEY"} + + def __init__(self): + self.reload_count = 0 + + async def on_config_changed(self): + self.reload_count += 1 + + +@pytest.fixture(params=[_ReloadRecorder, _AsyncReloadRecorder]) +def recorder(request, monkeypatch): + """将生成的处理器绑定到测试实例,并在测试后移除全局监听。""" + recorder_cls = request.param + instance = recorder_cls() + + def resolver(owner_class): + if owner_class is recorder_cls: + return EventHandlerBinding(instance=instance, owner_name=owner_class.__name__) + return None + + # 隔离真实解析器,仅绑定测试实例 + monkeypatch.setattr( + eventmanager, + "_EventManager__handler_instance_resolvers", + {"test_reload": resolver}, + ) + yield instance + # 移除类定义时注册的全局监听,避免影响其余测试 + eventmanager.remove_event_listener( + EventType.ConfigChanged, recorder_cls.handle_config_changed + ) + + +def _build_event(keys): + """构造携带指定配置键的 ConfigChanged 事件。""" + return Event(EventType.ConfigChanged, ConfigChangeEventData(key=set(keys))) + + +async def _dispatch(instance, event): + """按处理器类型走事件总线的同步或异步调用路径。""" + handler = instance.__class__.handle_config_changed + if inspect.iscoroutinefunction(handler): + await eventmanager._EventManager__invoke_handler_by_type_async(handler, event) + else: + eventmanager._EventManager__invoke_handler_by_type_sync(handler, event) + + +def test_generated_handler_exposes_method_name(recorder): + """生成的处理器 __name__ 必须与类上的方法名一致,保证事件总线能解析到实例方法。""" + handler = recorder.__class__.handle_config_changed + assert handler.__name__ == "handle_config_changed" + assert handler.__qualname__ == f"{recorder.__class__.__name__}.handle_config_changed" + + +@pytest.mark.asyncio +async def test_config_changed_event_triggers_reload(recorder): + """命中 CONFIG_WATCH 的配置变更事件必须触发子类重载逻辑。""" + await _dispatch(recorder, _build_event({"TEST_RELOAD_KEY"})) + assert recorder.reload_count == 1 + + +@pytest.mark.asyncio +async def test_unrelated_config_key_skips_reload(recorder): + """未监听的配置键变更不得触发重载。""" + await _dispatch(recorder, _build_event({"OTHER_KEY"})) + assert recorder.reload_count == 0 + + +@pytest.mark.asyncio +async def test_resolve_falls_back_to_qualname_when_name_mismatched(monkeypatch): + """处理器 __name__ 与类上方法名不一致时,事件总线应回退 __qualname__ 末段解析。""" + instance = _ReloadRecorder() + + def wrapper(event): # pylint: disable=unused-argument + """模拟只同步了 __qualname__ 的动态处理器。""" + instance.on_config_changed() + + wrapper.__module__ = _ReloadRecorder.__module__ + wrapper.__qualname__ = f"{_ReloadRecorder.__name__}.handle_config_changed" + + def resolver(owner_class): + if owner_class is _ReloadRecorder: + return EventHandlerBinding(instance=instance, owner_name=owner_class.__name__) + return None + + monkeypatch.setattr( + eventmanager, + "_EventManager__handler_instance_resolvers", + {"test_reload": resolver}, + ) + + await eventmanager._EventManager__invoke_handler_by_type_async( + wrapper, _build_event({"TEST_RELOAD_KEY"}) + ) + + assert wrapper.__name__ == "wrapper" + assert instance.reload_count == 1