mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-31 12:24:39 +08:00
fix(plugin): release reloaded plugin code held by framework caches (#6807)
This commit is contained in:
@@ -6,9 +6,24 @@ from threading import Lock
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.dependencies import models as fastapi_dependency_models
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
|
||||
def _clear_fastapi_callable_caches() -> None:
|
||||
"""清空 FastAPI 按可调用对象身份缓存的分类结果。
|
||||
|
||||
FastAPI 在模块级 ``lru_cache`` 中以强引用保存每个端点与依赖函数(容量 4096),
|
||||
插件路由移除后旧端点函数仍被缓存持有,进而经 ``__globals__`` 留住整个旧插件模块,
|
||||
每次重载都多留一份。缓存只记录“是否生成器/协程”这类分类结果,清空后下次请求按需
|
||||
重算。缓存属于 FastAPI 私有实现,按属性探测,版本不再提供时自然跳过。
|
||||
"""
|
||||
for value in vars(fastapi_dependency_models).values():
|
||||
cache_clear = getattr(value, "cache_clear", None)
|
||||
if callable(cache_clear):
|
||||
cache_clear()
|
||||
|
||||
|
||||
class FastAPIDynamicRouteRegistry:
|
||||
"""在 FastAPI 上注册插件自由响应路由,并维护 OpenAPI 缓存。"""
|
||||
|
||||
@@ -95,6 +110,7 @@ class FastAPIDynamicRouteRegistry:
|
||||
raise ValueError("Action must be 'add' or 'remove'")
|
||||
|
||||
modified = False
|
||||
removed = False
|
||||
existing_paths = {
|
||||
path: route
|
||||
for route in self._app.routes
|
||||
@@ -104,6 +120,7 @@ class FastAPIDynamicRouteRegistry:
|
||||
for current_id in plugin_ids:
|
||||
if self.remove(current_id):
|
||||
modified = True
|
||||
removed = True
|
||||
if action != "add":
|
||||
continue
|
||||
for source_api in self._plugin_apis(current_id):
|
||||
@@ -136,6 +153,9 @@ class FastAPIDynamicRouteRegistry:
|
||||
self.clean(existing_paths)
|
||||
self._app.openapi_schema = None
|
||||
self._app.setup()
|
||||
if removed:
|
||||
# 旧路由已不在路由表中,此后不会再有请求把旧端点写回缓存
|
||||
_clear_fastapi_callable_caches()
|
||||
|
||||
def remove(self, plugin_id: str) -> bool:
|
||||
"""移除指定插件前缀下的全部动态路由。"""
|
||||
|
||||
@@ -7,6 +7,7 @@ import importlib.util
|
||||
import sys
|
||||
import threading
|
||||
import traceback
|
||||
import typing
|
||||
from collections.abc import Callable, Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
@@ -37,6 +38,83 @@ def _undeclared_runtime(_: str) -> Mapping[str, bool]:
|
||||
PluginRuntimeStatusWriter = Callable[[str, PluginRuntimeStatus], None]
|
||||
|
||||
|
||||
# ---- 插件模块卸载后释放第三方类型缓存 ----
|
||||
# 插件重载或卸载只把模块移出 sys.modules;若旧插件类仍被进程级类型缓存引用,
|
||||
# 整份旧模块(函数、类、模块全局)会一直留在内存中,每重载一次多留一份。
|
||||
|
||||
# 类型参数嵌套深度上限,防止病态注解导致递归过深
|
||||
_MAX_TYPE_DEPTH = 8
|
||||
|
||||
|
||||
def release_plugin_type_caches(module_prefix: str) -> None:
|
||||
"""释放缓存中引用指定插件模块前缀下类型的条目。
|
||||
|
||||
- Pydantic 泛型模型缓存:值是弱引用,但键以强引用保存参数化用到的类,而参数化
|
||||
结果又引用这些类,两者互相保活永不释放;顶层参数化还会在泛型基类模块留下全局
|
||||
引用。只删除键里含有该插件类型的条目及其全局引用,其余模型的参数化结果与类身份
|
||||
保持不变。
|
||||
- ``typing`` 下标缓存:``Optional[X]`` 等注解按参数强引用缓存(每个函数 128 条),
|
||||
只影响注解构造性能,整体清空。
|
||||
|
||||
:param module_prefix: 插件模块前缀,例如 ``app.plugins.demo``;为 ``app.plugins`` 时覆盖全部插件
|
||||
"""
|
||||
_purge_pydantic_generic_cache(module_prefix)
|
||||
# typing 私有清理入口,与标准库测试清理类型缓存的方式一致;版本不提供时跳过
|
||||
for cleanup in getattr(typing, "_cleanups", ()):
|
||||
cleanup()
|
||||
|
||||
|
||||
def _purge_pydantic_generic_cache(module_prefix: str) -> None:
|
||||
"""删除 Pydantic 泛型缓存中键引用了插件类型的条目,以及它们留下的模块全局引用。"""
|
||||
try:
|
||||
from pydantic._internal._generics import _GENERIC_TYPES_CACHE
|
||||
except ImportError:
|
||||
# 私有实现随 Pydantic 版本变化,缺失时不影响插件卸载
|
||||
return
|
||||
for key in list(_GENERIC_TYPES_CACHE.keys()):
|
||||
if not _references_module(key, module_prefix, 0):
|
||||
continue
|
||||
model = _GENERIC_TYPES_CACHE.pop(key, None)
|
||||
if model is not None:
|
||||
_drop_pickle_reference(model)
|
||||
|
||||
|
||||
def _drop_pickle_reference(model: type) -> None:
|
||||
"""移除 Pydantic 为支持 pickle 写入泛型基类所在模块的全局引用。
|
||||
|
||||
在模块顶层参数化泛型模型(如 ``RootModel[list[Payload]]``)时,Pydantic 会把结果以
|
||||
``RootModel[list[Payload]]``(重名时追加下划线)登记到泛型基类所在模块的全局命名空间,
|
||||
该引用与缓存无关、永不删除。只移除以模型名开头且确实指向该模型的登记项。
|
||||
"""
|
||||
module = sys.modules.get(model.__module__)
|
||||
if module is None:
|
||||
return
|
||||
namespace = vars(module)
|
||||
for name, value in list(namespace.items()):
|
||||
if value is model and name.startswith(model.__name__):
|
||||
namespace.pop(name, None)
|
||||
|
||||
|
||||
def _references_module(value: Any, module_prefix: str, depth: int) -> bool:
|
||||
"""判断缓存键(类型、元组或参数化注解)是否引用了指定模块前缀下定义的类型。"""
|
||||
if depth > _MAX_TYPE_DEPTH:
|
||||
return False
|
||||
if isinstance(value, type):
|
||||
module = value.__dict__.get("__module__")
|
||||
return isinstance(module, str) and (
|
||||
module == module_prefix or module.startswith(f"{module_prefix}.")
|
||||
)
|
||||
if isinstance(value, tuple):
|
||||
return any(_references_module(item, module_prefix, depth + 1) for item in value)
|
||||
origin = typing.get_origin(value)
|
||||
if origin is None:
|
||||
return False
|
||||
return _references_module(origin, module_prefix, depth + 1) or any(
|
||||
_references_module(arg, module_prefix, depth + 1)
|
||||
for arg in typing.get_args(value)
|
||||
)
|
||||
|
||||
|
||||
class PluginLoader:
|
||||
"""只负责从运行目录发现插件类,并维护对应模块缓存。"""
|
||||
|
||||
@@ -393,6 +471,9 @@ class PluginLoader:
|
||||
for module_name in removed:
|
||||
sys.modules.pop(module_name, None)
|
||||
self._logger.debug(f"已清除插件模块缓存:{module_name}")
|
||||
if removed:
|
||||
# 只移出 sys.modules 不足以释放旧模块:第三方类型缓存仍强引用旧插件类
|
||||
release_plugin_type_caches(prefix)
|
||||
importlib.invalidate_caches()
|
||||
self._logger.debug("已清除查找器的缓存")
|
||||
if plugin_id:
|
||||
|
||||
125
tests/test_plugin_reload_cache_release.py
Normal file
125
tests/test_plugin_reload_cache_release.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""插件重载与路由移除后,旧插件代码不得被第三方进程级缓存留在内存中。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import sys
|
||||
import types
|
||||
import typing
|
||||
import weakref
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.adapters.web.plugin.routes import FastAPIDynamicRouteRegistry
|
||||
from app.runtime.extensions.plugin.loader import PluginLoader
|
||||
|
||||
_SILENT_LOG = SimpleNamespace(
|
||||
debug=lambda *_args: None,
|
||||
info=lambda *_args: None,
|
||||
warning=lambda *_args: None,
|
||||
error=lambda *_args: None,
|
||||
)
|
||||
|
||||
|
||||
def _install_plugin_module(name: str, source: str) -> types.ModuleType:
|
||||
"""以插件运行时命名空间登记一个内存模块,模拟已导入的插件源码。"""
|
||||
module = types.ModuleType(name)
|
||||
sys.modules[name] = module
|
||||
exec(compile(source, f"<{name}>", "exec"), module.__dict__)
|
||||
return module
|
||||
|
||||
|
||||
def test_removed_plugin_route_releases_endpoint_from_fastapi_caches() -> None:
|
||||
"""插件路由移除后,FastAPI 可调用分类缓存不再持有旧端点及其模块。"""
|
||||
module = _install_plugin_module(
|
||||
"app.plugins.routecachedemo",
|
||||
"async def health():\n return {'ok': True}\n",
|
||||
)
|
||||
apis: list[dict] = [
|
||||
{
|
||||
"path": "/RouteCacheDemo/health",
|
||||
"endpoint": module.health,
|
||||
"methods": ["GET"],
|
||||
"allow_anonymous": True,
|
||||
}
|
||||
]
|
||||
app = FastAPI()
|
||||
registry = FastAPIDynamicRouteRegistry(
|
||||
app=app,
|
||||
plugin_ids=lambda: ["RouteCacheDemo"],
|
||||
plugin_apis=lambda _plugin_id: apis,
|
||||
verify_token=lambda: None,
|
||||
verify_apikey=lambda: None,
|
||||
prefix="/api/v1/plugin",
|
||||
protected_routes=set(),
|
||||
log=_SILENT_LOG,
|
||||
)
|
||||
registry.update("RouteCacheDemo", "add")
|
||||
# 真实请求让 FastAPI 把端点写入分类缓存
|
||||
with TestClient(app) as client:
|
||||
assert client.get("/api/v1/plugin/RouteCacheDemo/health").json() == {"ok": True}
|
||||
|
||||
endpoint_ref = weakref.ref(module.health)
|
||||
apis.clear()
|
||||
sys.modules.pop(module.__name__)
|
||||
del module
|
||||
registry.update("RouteCacheDemo", "remove")
|
||||
gc.collect()
|
||||
|
||||
assert endpoint_ref() is None
|
||||
|
||||
|
||||
def test_clear_modules_releases_plugin_classes_from_type_caches(tmp_path: Path) -> None:
|
||||
"""清除插件模块缓存后,Pydantic 泛型缓存与 typing 缓存不再持有旧插件类。"""
|
||||
module = _install_plugin_module(
|
||||
"app.plugins.typecachedemo",
|
||||
"from typing import Optional\n"
|
||||
"from pydantic import BaseModel, RootModel\n"
|
||||
"class Payload(BaseModel):\n"
|
||||
" title: str\n"
|
||||
"PayloadList = RootModel[list[Payload]]\n"
|
||||
"MaybePayload = Optional[Payload]\n",
|
||||
)
|
||||
# 对照:其他模块的参数化结果不应被清掉,类身份保持不变
|
||||
host_model = _install_plugin_module(
|
||||
"app.schemas.typecachehost",
|
||||
"from pydantic import BaseModel, RootModel\n"
|
||||
"class HostItem(BaseModel):\n"
|
||||
" name: str\n",
|
||||
)
|
||||
from pydantic import RootModel
|
||||
|
||||
host_list = RootModel[list[host_model.HostItem]]
|
||||
payload_ref = weakref.ref(module.Payload)
|
||||
loader = PluginLoader(
|
||||
plugins_root=tmp_path,
|
||||
import_preparer=lambda *_args, **_kwargs: None,
|
||||
import_scanner=lambda *_args, **_kwargs: None,
|
||||
log=_SILENT_LOG,
|
||||
)
|
||||
|
||||
removed = loader.clear_modules("TypeCacheDemo")
|
||||
del module
|
||||
gc.collect()
|
||||
|
||||
assert removed == ["app.plugins.typecachedemo"]
|
||||
assert payload_ref() is None
|
||||
assert RootModel[list[host_model.HostItem]] is host_list
|
||||
sys.modules.pop(host_model.__name__)
|
||||
|
||||
|
||||
def test_clear_modules_without_matching_modules_keeps_type_caches(tmp_path: Path) -> None:
|
||||
"""没有可清除的插件模块时不触碰全局类型缓存。"""
|
||||
cached = typing.Optional[int]
|
||||
loader = PluginLoader(
|
||||
plugins_root=tmp_path,
|
||||
import_preparer=lambda *_args, **_kwargs: None,
|
||||
import_scanner=lambda *_args, **_kwargs: None,
|
||||
log=_SILENT_LOG,
|
||||
)
|
||||
|
||||
assert loader.clear_modules("NotLoadedPlugin") == []
|
||||
assert typing.Optional[int] is cached
|
||||
Reference in New Issue
Block a user