Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions astrbot/core/star/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 文档查看更好的注册方式。
"""
Expand Down
19 changes: 19 additions & 0 deletions astrbot/core/star/star_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Comment on lines +1941 to +1942

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (broader_impact): Disabling a plugin through turn_off_plugin() does not call _unbind_plugin(), so this new cleanup path is never reached and the plugin's registered web APIs remain routable after the plugin is disabled. Requests continue invoking the disabled plugin's handlers instead of returning 404.

Triggers: When a loaded plugin is disabled without being uninstalled or reloaded.

Suggested fix: Invoke the web API cleanup from the disable path, or route disabling through _unbind_plugin() while preserving the plugin metadata needed for re-enabling.

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

Expand Down
85 changes: 85 additions & 0 deletions tests/test_plugin_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading