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
12 changes: 12 additions & 0 deletions astrbot/core/star/star_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,18 @@ async def reload(self, specified_plugin_name=None):
specified_module_path = smd.module_path
break

if specified_module_path:
inactivated_plugins = await sp.global_get("inactivated_plugins", [])

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.

medium

To ensure defensive programming and robust handling of potential null/None values in the configuration, it is safer to append or [] when retrieving inactivated_plugins. If the key exists in the shared preferences but is explicitly set to null (or None), sp.global_get will return None, which would subsequently cause a TypeError when executing specified_module_path in inactivated_plugins.

Suggested change
inactivated_plugins = await sp.global_get("inactivated_plugins", [])
inactivated_plugins = await sp.global_get("inactivated_plugins", []) or []

if specified_module_path in inactivated_plugins:
# 已停用插件没有实例,无需重载;此处若继续执行 _unbind_plugin,
# 会将其在 __init__ 中通过 add_llm_tools 注册的工具从
# func_list 中移除且无法恢复(load 会跳过停用插件的实例化)。
# 新配置会在重新启用插件时由完整的 reload 流程应用。见 #8582。
logger.info(
f"插件 {specified_plugin_name} 处于禁用状态,跳过重载。",
)
return True, None

# 终止插件
if not specified_module_path:
# 重载所有插件
Expand Down
114 changes: 114 additions & 0 deletions tests/test_plugin_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,120 @@ async def mock_load(
assert unbound == plugin_names


@pytest.mark.asyncio
async def test_reload_skips_deactivated_plugin(
plugin_manager_pm: PluginManager, monkeypatch
):
"""Reloading a deactivated plugin must not unbind it (#8582).

_terminate_plugin short-circuits for deactivated plugins, but
_unbind_plugin still removed their tools from func_list while load()
skipped re-instantiation, losing every tool registered via
add_llm_tools() in the plugin's __init__.
"""
_clear_star_runtime_state()
plugin_name = "plugin_one"
module_path = f"data.plugins.{plugin_name}.main"
metadata = star_manager_module.StarMetadata(
name=plugin_name,
root_dir_name=plugin_name,
module_path=module_path,
activated=False,
)
star_manager_module.star_map[module_path] = metadata
star_manager_module.star_registry.append(metadata)

calls = []

async def mock_global_get(key, default=None):
if key == "inactivated_plugins":
return [module_path]
return default

async def mock_terminate(plugin):
calls.append(("terminate", plugin.name))

async def mock_unbind(plugin_name, plugin_module_path):
calls.append(("unbind", plugin_name))

async def mock_load(
specified_module_path=None,
specified_dir_name=None,
ignore_version_check=False,
):
calls.append(("load", specified_module_path))
return True, None

monkeypatch.setattr(star_manager_module.sp, "global_get", mock_global_get)
monkeypatch.setattr(plugin_manager_pm, "_terminate_plugin", mock_terminate)
monkeypatch.setattr(plugin_manager_pm, "_unbind_plugin", mock_unbind)
monkeypatch.setattr(plugin_manager_pm, "load", mock_load)

try:
result = await plugin_manager_pm.reload(plugin_name)
assert result == (True, None)
assert calls == []
assert module_path in star_manager_module.star_map
assert any(smd.name == plugin_name for smd in star_manager_module.star_registry)
finally:
_clear_star_runtime_state()


@pytest.mark.asyncio
async def test_reload_runs_full_cycle_for_activated_plugin(
plugin_manager_pm: PluginManager, monkeypatch
):
"""Activated plugins keep the full terminate -> unbind -> load cycle."""
_clear_star_runtime_state()
plugin_name = "plugin_one"
module_path = f"data.plugins.{plugin_name}.main"
metadata = star_manager_module.StarMetadata(
name=plugin_name,
root_dir_name=plugin_name,
module_path=module_path,
)
star_manager_module.star_map[module_path] = metadata
star_manager_module.star_registry.append(metadata)

calls = []

async def mock_global_get(key, default=None):
if key == "inactivated_plugins":
return []
return default

async def mock_terminate(plugin):
calls.append(("terminate", plugin.name))

async def mock_unbind(plugin_name, plugin_module_path):
calls.append(("unbind", plugin_name))
star_manager_module.star_map.pop(plugin_module_path, None)

async def mock_load(
specified_module_path=None,
specified_dir_name=None,
ignore_version_check=False,
):
calls.append(("load", specified_module_path))
return True, None

monkeypatch.setattr(star_manager_module.sp, "global_get", mock_global_get)
monkeypatch.setattr(plugin_manager_pm, "_terminate_plugin", mock_terminate)
monkeypatch.setattr(plugin_manager_pm, "_unbind_plugin", mock_unbind)
monkeypatch.setattr(plugin_manager_pm, "load", mock_load)

try:
result = await plugin_manager_pm.reload(plugin_name)
assert result == (True, None)
assert calls == [
("terminate", plugin_name),
("unbind", plugin_name),
("load", module_path),
]
finally:
_clear_star_runtime_state()


@pytest.mark.asyncio
async def test_load_reports_unregistered_plugin_without_index_error(
plugin_manager_pm: PluginManager, monkeypatch
Expand Down