mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
fix: 修复批量插件重载窗口期残留 handler 直调导致 missing event TypeError
插件重载 stop 阶段清除 sys.modules 缓存后,若广播事件命中注册表中残留的旧类方法声明, EventBindingResolver.owner_class 因 inspect.getmodule 返回 None 而解析失败,原实现会 直接返回原始函数并绕过实例绑定,旧签名无 event 参数时抛出 TypeError 并刷屏通知。 - binding.py:owner_class 解析失败时区分类方法声明与自由函数;类方法声明记入 unresolved 诊断并跳过执行,等待重载完成后新 handler 按标识覆盖自愈; 自由函数保持直调路径不变。 - 收敛 binding-miss 与新路径的未命中记账逻辑到 _record_unresolved,首次未命中才告警。 - 新增回归测试:模块卸载后残留类方法被跳过、自由函数直调不受影响。 Closes #6351
This commit is contained in:
@@ -57,6 +57,12 @@ class EventBindingResolver:
|
|||||||
return "", names[0]
|
return "", names[0]
|
||||||
return names[0], names[1]
|
return names[0], names[1]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_class_method_declaration(handler: Callable) -> bool:
|
||||||
|
"""判断处理器是否声明在类体内(限定名含类前缀且非局部闭包)。"""
|
||||||
|
parts = handler.__qualname__.split(".")
|
||||||
|
return len(parts) >= 2 and "<locals>" not in parts
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def owner_class(handler: Callable) -> Optional[Type[Any]]:
|
def owner_class(handler: Callable) -> Optional[Type[Any]]:
|
||||||
"""从处理器对象本身解析声明类,不按字符串动态导入模块。"""
|
"""从处理器对象本身解析声明类,不按字符串动态导入模块。"""
|
||||||
@@ -75,6 +81,14 @@ class EventBindingResolver:
|
|||||||
return None
|
return None
|
||||||
return owner if isinstance(owner, type) else 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(
|
def resolve(
|
||||||
self,
|
self,
|
||||||
handler: Callable,
|
handler: Callable,
|
||||||
@@ -87,6 +101,15 @@ class EventBindingResolver:
|
|||||||
self.parse_handler_names(handler)[1],
|
self.parse_handler_names(handler)[1],
|
||||||
)
|
)
|
||||||
if owner_class is None:
|
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(
|
binding = EventHandlerBinding(
|
||||||
instance=None,
|
instance=None,
|
||||||
owner_name=EventRegistry.handler_identifier(handler),
|
owner_name=EventRegistry.handler_identifier(handler),
|
||||||
@@ -105,15 +128,10 @@ class EventBindingResolver:
|
|||||||
resolver_name = name
|
resolver_name = name
|
||||||
break
|
break
|
||||||
if binding is None:
|
if binding is None:
|
||||||
identifier = EventRegistry.handler_identifier(handler)
|
self._record_unresolved(
|
||||||
with self._lock:
|
EventRegistry.handler_identifier(handler),
|
||||||
first_miss = identifier not in self._unresolved
|
"事件处理器未绑定显式 resolver,已跳过:%s",
|
||||||
self._unresolved.add(identifier)
|
)
|
||||||
if first_miss:
|
|
||||||
logger.warning(
|
|
||||||
"事件处理器未绑定显式 resolver,已跳过:%s",
|
|
||||||
identifier,
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"事件处理器绑定:%s -> %s",
|
"事件处理器绑定:%s -> %s",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""事件注册、绑定、调度和错误策略组件的独立测试。"""
|
"""事件注册、绑定、调度和错误策略组件的独立测试。"""
|
||||||
|
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
import types
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
from app.runtime.event.binding import (
|
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:
|
def test_binding_miss_does_not_construct_handler_owner() -> None:
|
||||||
"""resolver 未命中时只记录诊断,不能调用 owner_class()。"""
|
"""resolver 未命中时只记录诊断,不能调用 owner_class()。"""
|
||||||
resolvers = {}
|
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:
|
def test_binding_uses_explicit_resolver_instance() -> None:
|
||||||
"""显式 resolver 应返回当前托管实例上的绑定方法。"""
|
"""显式 resolver 应返回当前托管实例上的绑定方法。"""
|
||||||
instance = object.__new__(_UnmanagedHandler)
|
instance = object.__new__(_UnmanagedHandler)
|
||||||
|
|||||||
Reference in New Issue
Block a user