diff --git a/app/adapters/web/plugin/routes.py b/app/adapters/web/plugin/routes.py index 5ebb1c5c8..c87a942f1 100644 --- a/app/adapters/web/plugin/routes.py +++ b/app/adapters/web/plugin/routes.py @@ -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: """移除指定插件前缀下的全部动态路由。""" diff --git a/app/runtime/extensions/plugin/loader.py b/app/runtime/extensions/plugin/loader.py index e3c7e67f2..01686aa58 100644 --- a/app/runtime/extensions/plugin/loader.py +++ b/app/runtime/extensions/plugin/loader.py @@ -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: diff --git a/tests/test_plugin_reload_cache_release.py b/tests/test_plugin_reload_cache_release.py new file mode 100644 index 000000000..15a969ee0 --- /dev/null +++ b/tests/test_plugin_reload_cache_release.py @@ -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