From 61785b6635bc8c8a2d0b1ab1b1e38de4700a19ec Mon Sep 17 00:00:00 2001 From: lxfight <1686540385@qq.com> Date: Wed, 2 Sep 2026 13:51:45 +0800 Subject: [PATCH] fix: unregister plugin web APIs on plugin unload Context.registered_web_apis kept handlers of uninstalled, disabled and reloaded plugins forever, leaking plugin instances via bound method references and leaving ghost routes callable after unload. - Add Context.unregister_web_apis() to drop web APIs owned by a plugin module path prefix (function module and owner class module). - Call it from PluginManager._unbind_plugin() and _cleanup_plugin_state(). - Clear all registered web APIs on reload(all), matching the existing registry clear semantics. --- astrbot/core/star/context.py | 45 ++++++++++++++++ astrbot/core/star/star_manager.py | 19 +++++++ tests/test_plugin_manager.py | 85 +++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+) diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py index b4f6e61c48..db44ec266e 100644 --- a/astrbot/core/star/context.py +++ b/astrbot/core/star/context.py @@ -726,6 +726,51 @@ def register_web_api( return self.registered_web_apis.append((route, view_handler, methods, desc)) + @classmethod + def unregister_web_apis(cls, module_path: str) -> int: + """Unregister all web APIs registered by a plugin. + + Args: + module_path: Plugin module path prefix, e.g. ``data.plugins.my_plugin``. + Handlers whose defining module equals this path or resides under it + are removed, so that unloading a plugin releases its handlers and + avoids ghost routes. + + Returns: + The number of web API registrations removed. + """ + if not module_path: + return 0 + prefix = f"{module_path}." + remaining: list[RegisteredWebApi] = [] + removed = 0 + for api in cls.registered_web_apis: + handler = api[1] + handler_modules = set() + handler_module = getattr(handler, "__module__", None) + if isinstance(handler_module, str): + handler_modules.add(handler_module) + # Bound methods also expose the owner class module, which may + # differ from the function module in edge cases. + owner = getattr(handler, "__self__", None) + if owner is not None: + class_module = type(owner).__module__ + if isinstance(class_module, str): + handler_modules.add(class_module) + if any( + module == module_path or module.startswith(prefix) + for module in handler_modules + ): + removed += 1 + logger.debug( + f"Removed registered web API {api[0]} owned by {module_path}" + ) + continue + remaining.append(api) + if removed: + cls.registered_web_apis[:] = remaining + return removed + """ 以下的方法已经不推荐使用。请从 AstrBot 文档查看更好的注册方式。 """ diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index c9ef92e5ce..a75aa88485 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -807,6 +807,13 @@ def _cleanup_plugin_state(self, dir_name: str, is_reserved: bool = False) -> Non llm_tools.func_list.remove(tool) logger.info(f"Removed tool: {tool.name}") + # 清理插件注册的 Web API + removed_web_apis = Context.unregister_web_apis(module_prefix) + if removed_web_apis: + logger.info( + f"Removed {removed_web_apis} registered web API(s) from plugin {dir_name}", + ) + for adapter_name in unregister_platform_adapters_by_module(module_prefix): logger.info(f"Removed platform adapter: {adapter_name}") @@ -1046,6 +1053,9 @@ async def reload(self, specified_plugin_name=None): star_handlers_registry.clear() star_map.clear() star_registry.clear() + # Clear web APIs registered by plugins; every plugin has been + # unbound above and will be re-registered by load(). + Context.registered_web_apis.clear() else: # 只重载指定插件 smd = star_map.get(specified_module_path) @@ -1927,6 +1937,15 @@ async def _unbind_plugin(self, plugin_name: str, plugin_module_path: str) -> Non f"Removed platform adapter {adapter_name} from plugin {plugin_name}", ) + # Unregister web APIs registered by this plugin to avoid ghost + # routes and memory leaks after unloading. + removed_web_apis = Context.unregister_web_apis(module_prefix) + if removed_web_apis: + logger.info( + f"Removed {removed_web_apis} registered web API(s) " + f"from plugin {plugin_name}", + ) + if plugin is None: return diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py index 5cb8747ebb..40c8a81b96 100644 --- a/tests/test_plugin_manager.py +++ b/tests/test_plugin_manager.py @@ -10,6 +10,7 @@ import yaml from astrbot.core.star import star_manager as star_manager_module +from astrbot.core.star.context import Context from astrbot.core.star.star_handler import EventType, StarHandlerMetadata from astrbot.core.star.star_manager import PluginDependencyInstallError, PluginManager from astrbot.core.utils.pip_installer import PipInstallError @@ -625,6 +626,90 @@ async def mock_load( assert unbound == plugin_names +def _make_bound_web_api_handler(module_name: str): + """Creates a bound method handler whose owner class lives in ``module_name``.""" + + class Owner: + pass + + Owner.__module__ = module_name + + async def handler(self): + return {} + + Owner.handler = handler + return Owner().handler + + +def _make_web_api_function(module_name: str): + async def handler(): + return {} + + handler.__module__ = module_name + return handler + + +def test_unregister_web_apis_removes_only_owner_plugin_apis(): + plugin_handler = _make_bound_web_api_handler("data.plugins.demo_plugin.main") + child_handler = _make_web_api_function("data.plugins.demo_plugin.tools.utils") + other_handler = _make_bound_web_api_handler("data.plugins.other_plugin.main") + core_handler = _make_web_api_function("astrbot.dashboard.api.plugins") + + original_apis = list(Context.registered_web_apis) + try: + Context.registered_web_apis[:] = [ + ("/demo/test", plugin_handler, ["GET"], "demo"), + ("/demo/tools", child_handler, ["POST"], "demo tools"), + ("/other/test", other_handler, ["GET"], "other"), + ("/core/test", core_handler, ["GET"], "core"), + ] + + assert Context.unregister_web_apis("data.plugins.demo_plugin") == 2 + assert [api[0] for api in Context.registered_web_apis] == [ + "/other/test", + "/core/test", + ] + assert Context.unregister_web_apis("") == 0 + finally: + Context.registered_web_apis[:] = original_apis + + +@pytest.mark.asyncio +async def test_unbind_plugin_removes_registered_web_apis( + plugin_manager_pm: PluginManager, +): + _clear_star_runtime_state() + module_path = "data.plugins.demo_plugin.main" + metadata = star_manager_module.StarMetadata( + name="demo_plugin", + root_dir_name="demo_plugin", + module_path=module_path, + ) + star_manager_module.star_map[module_path] = metadata + star_manager_module.star_registry.append(metadata) + + plugin_handler = _make_bound_web_api_handler(module_path) + child_handler = _make_web_api_function("data.plugins.demo_plugin.tools") + other_handler = _make_bound_web_api_handler("data.plugins.other_plugin.main") + + original_apis = list(Context.registered_web_apis) + try: + Context.registered_web_apis[:] = [ + ("/demo/api", plugin_handler, ["GET"], "demo"), + ("/demo/tools/api", child_handler, ["POST"], "demo tools"), + ("/other/api", other_handler, ["GET"], "other"), + ] + + await plugin_manager_pm._unbind_plugin("demo_plugin", module_path) + + assert [(api[0], api[1]) for api in Context.registered_web_apis] == [ + ("/other/api", other_handler) + ] + finally: + Context.registered_web_apis[:] = original_apis + _clear_star_runtime_state() + + @pytest.mark.asyncio async def test_turn_plugin_toggles_llm_tools_from_plugin_child_module( plugin_manager_pm: PluginManager,