Files
MoviePilot/app/runtime/event/binding.py
jxxghp 282b922f1b 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
2026-08-18 21:40:13 +08:00

157 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""事件处理器声明到运行实例的显式绑定解析。"""
from __future__ import annotations
import inspect
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Optional, Type
from app.runtime.event.registry import EventRegistry
from app.runtime.log import logger
@dataclass(frozen=True, slots=True)
class EventHandlerBinding:
"""描述上层运行时为某个事件处理器提供的实例绑定。"""
instance: Optional[Any]
owner_name: str
run_sync_in_threadpool: bool = False
HandlerInstanceResolver = Callable[
[Type[Any]], Optional[EventHandlerBinding]
]
class EventBindingResolver:
"""只通过已登记 resolver 把类处理器绑定到托管运行实例。"""
def __init__(
self,
*,
lock: Any,
resolvers: Callable[[], dict[str, HandlerInstanceResolver]],
) -> None:
"""绑定 resolver 存储,并记录未命中的处理器用于启动诊断。"""
self._lock = lock
self._resolvers = resolvers
self._unresolved: set[str] = set()
def register(self, name: str, resolver: HandlerInstanceResolver) -> None:
"""注册或替换命名实例解析器。"""
with self._lock:
self._resolvers()[name] = resolver
def unresolved_handlers(self) -> tuple[str, ...]:
"""返回本进程中未被显式 resolver 接管的类处理器。"""
with self._lock:
return tuple(sorted(self._unresolved))
@staticmethod
def parse_handler_names(handler: Callable) -> tuple[str, str]:
"""解析处理器限定名中的类名和方法名。"""
names = handler.__qualname__.split(".")
if len(names) < 2:
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 "<locals>" not in parts
@staticmethod
def owner_class(handler: Callable) -> Optional[Type[Any]]:
"""从处理器对象本身解析声明类,不按字符串动态导入模块。"""
if inspect.ismethod(handler):
owner = handler.__self__
return owner if isinstance(owner, type) else type(owner)
module = inspect.getmodule(handler)
if not module:
return None
owner: Any = module
for part in handler.__qualname__.split(".")[:-1]:
if part == "<locals>":
return None
owner = getattr(owner, part, None)
if owner is None:
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,
) -> Optional[tuple[Callable, EventHandlerBinding, str, str]]:
"""通过显式 resolver 解析当前实例方法;自由函数直接返回。"""
owner_class = self.owner_class(handler)
method_name = getattr(
handler,
"__name__",
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),
run_sync_in_threadpool=True,
)
return handler, binding, "", method_name
with self._lock:
resolvers = tuple(self._resolvers().items())
binding = None
resolver_name = ""
for name, resolver in resolvers:
candidate = resolver(owner_class)
if candidate is not None:
binding = candidate
resolver_name = name
break
if binding is None:
self._record_unresolved(
EventRegistry.handler_identifier(handler),
"事件处理器未绑定显式 resolver,已跳过:%s",
)
return None
logger.debug(
"事件处理器绑定:%s -> %s",
EventRegistry.handler_identifier(handler),
resolver_name,
)
if binding.instance is None:
return None
method = getattr(binding.instance, method_name, None)
if not callable(method):
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(
"事件处理器 %s 无法解析为实例方法 %s.%s,跳过执行",
EventRegistry.handler_identifier(handler),
owner_class.__name__,
method_name,
)
return None
method_name = fallback_name
return method, binding, owner_class.__name__, method_name