mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 07:56:52 +08:00
refactor: close plugin shutdown mutation races
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
"""通用系统设置入口的插件 mutation 准入测试。"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.agent.tools.impl.update_system_settings import UpdateSystemSettingsTool
|
||||
from app.api.endpoints import system as system_endpoint
|
||||
from app.application.plugin import runtime as plugin_runtime
|
||||
from app.runtime.extensions.plugin.admission import PluginMutationAdmission
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
|
||||
PLUGIN_RUNTIME_KEYS = (
|
||||
SystemConfigKey.UserInstalledPlugins,
|
||||
SystemConfigKey.PluginInstances,
|
||||
SystemConfigKey.PluginFolders,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("config_key", PLUGIN_RUNTIME_KEYS)
|
||||
async def test_system_http_rejects_plugin_keys_after_admission_seal(
|
||||
config_key: SystemConfigKey,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""HTTP 通用设置入口在封口后不得写入插件运行态配置。"""
|
||||
admission = PluginMutationAdmission()
|
||||
admission.seal()
|
||||
config = MagicMock()
|
||||
config.async_set = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
plugin_runtime,
|
||||
"get_plugin_manager",
|
||||
lambda: SimpleNamespace(mutation=admission.hold),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
system_endpoint,
|
||||
"get_runtime_settings",
|
||||
lambda: SimpleNamespace(contains=lambda _key: False),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
system_endpoint,
|
||||
"get_configured_system_config",
|
||||
lambda: config,
|
||||
)
|
||||
|
||||
response = await system_endpoint.set_setting(config_key.value, {}, None)
|
||||
|
||||
assert response.success is False
|
||||
assert "停机阶段" in response.message
|
||||
config.async_set.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("config_key", PLUGIN_RUNTIME_KEYS)
|
||||
async def test_agent_rejects_plugin_keys_after_admission_seal(
|
||||
config_key: SystemConfigKey,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""Agent 通用设置入口在封口后不得读取或写入插件运行态配置。"""
|
||||
admission = PluginMutationAdmission()
|
||||
admission.seal()
|
||||
config = MagicMock()
|
||||
config.async_set = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
plugin_runtime,
|
||||
"get_plugin_manager",
|
||||
lambda: SimpleNamespace(mutation=admission.hold),
|
||||
)
|
||||
tool = UpdateSystemSettingsTool(
|
||||
session_id="session-1",
|
||||
user_id="10001",
|
||||
system_config=config,
|
||||
)
|
||||
|
||||
payload = json.loads(await tool.run(setting_key=config_key.value, value={}))
|
||||
|
||||
assert payload["success"] is False
|
||||
assert "停机阶段" in payload["message"]
|
||||
config.get.assert_not_called()
|
||||
config.async_set.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_http_plugin_write_remains_owned_until_settled(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""HTTP 在途插件配置写入完成前,封口等待不得误判为空闲。"""
|
||||
admission = PluginMutationAdmission()
|
||||
write_started = asyncio.Event()
|
||||
write_release = asyncio.Event()
|
||||
|
||||
async def async_set(_key: str, _value: object) -> bool:
|
||||
"""阻塞配置写入,暴露 quiesce 等待窗口。"""
|
||||
write_started.set()
|
||||
await write_release.wait()
|
||||
return True
|
||||
|
||||
config = MagicMock()
|
||||
config.async_set = AsyncMock(side_effect=async_set)
|
||||
monkeypatch.setattr(
|
||||
plugin_runtime,
|
||||
"get_plugin_manager",
|
||||
lambda: SimpleNamespace(mutation=admission.hold),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
system_endpoint,
|
||||
"get_runtime_settings",
|
||||
lambda: SimpleNamespace(contains=lambda _key: False),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
system_endpoint,
|
||||
"get_configured_system_config",
|
||||
lambda: config,
|
||||
)
|
||||
monkeypatch.setattr(system_endpoint.eventmanager, "async_send_event", AsyncMock())
|
||||
|
||||
task = asyncio.create_task(
|
||||
system_endpoint.set_setting(
|
||||
SystemConfigKey.UserInstalledPlugins.value,
|
||||
["DemoPlugin"],
|
||||
None,
|
||||
)
|
||||
)
|
||||
await write_started.wait()
|
||||
assert admission.seal() == 1
|
||||
idle_waiter = asyncio.create_task(asyncio.to_thread(admission.wait_until_idle))
|
||||
await asyncio.sleep(0.02)
|
||||
assert idle_waiter.done() is False
|
||||
|
||||
write_release.set()
|
||||
response = await task
|
||||
await idle_waiter
|
||||
assert response.success is True
|
||||
assert admission.active_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_plugin_write_remains_owned_until_saved_value_read(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""Agent 插件配置事务在写入和结果读取完成前持续持有 lease。"""
|
||||
admission = PluginMutationAdmission()
|
||||
write_started = asyncio.Event()
|
||||
write_release = asyncio.Event()
|
||||
|
||||
async def async_set(_key: SystemConfigKey, _value: object) -> bool:
|
||||
"""阻塞 Agent 配置写入,暴露 quiesce 等待窗口。"""
|
||||
write_started.set()
|
||||
await write_release.wait()
|
||||
return True
|
||||
|
||||
config = MagicMock()
|
||||
config.get.side_effect = [[], ["DemoPlugin"]]
|
||||
config.async_set = AsyncMock(side_effect=async_set)
|
||||
monkeypatch.setattr(
|
||||
plugin_runtime,
|
||||
"get_plugin_manager",
|
||||
lambda: SimpleNamespace(mutation=admission.hold),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.agent.tools.impl.update_system_settings.eventmanager.async_send_event",
|
||||
AsyncMock(),
|
||||
)
|
||||
tool = UpdateSystemSettingsTool(
|
||||
session_id="session-1",
|
||||
user_id="10001",
|
||||
system_config=config,
|
||||
)
|
||||
|
||||
task = asyncio.create_task(
|
||||
tool.run(
|
||||
setting_key=SystemConfigKey.UserInstalledPlugins.value,
|
||||
value=["DemoPlugin"],
|
||||
)
|
||||
)
|
||||
await write_started.wait()
|
||||
assert admission.seal() == 1
|
||||
idle_waiter = asyncio.create_task(asyncio.to_thread(admission.wait_until_idle))
|
||||
await asyncio.sleep(0.02)
|
||||
assert idle_waiter.done() is False
|
||||
|
||||
write_release.set()
|
||||
payload = json.loads(await task)
|
||||
await idle_waiter
|
||||
assert payload["success"] is True
|
||||
assert payload["saved_value"] == ["DemoPlugin"]
|
||||
assert admission.active_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_plugin_system_config_does_not_resolve_plugin_runtime(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""非插件 SystemConfig 写入保持原路径且不依赖插件组合根。"""
|
||||
config = MagicMock()
|
||||
config.async_set = AsyncMock(return_value=True)
|
||||
|
||||
def fail_runtime_resolution():
|
||||
"""若非插件配置误取插件运行时则立即暴露回归。"""
|
||||
raise AssertionError("非插件配置不应解析插件运行时")
|
||||
|
||||
monkeypatch.setattr(plugin_runtime, "get_plugin_manager", fail_runtime_resolution)
|
||||
monkeypatch.setattr(
|
||||
system_endpoint,
|
||||
"get_runtime_settings",
|
||||
lambda: SimpleNamespace(contains=lambda _key: False),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
system_endpoint,
|
||||
"get_configured_system_config",
|
||||
lambda: config,
|
||||
)
|
||||
monkeypatch.setattr(system_endpoint.eventmanager, "async_send_event", AsyncMock())
|
||||
|
||||
response = await system_endpoint.set_setting(
|
||||
SystemConfigKey.Directories.value,
|
||||
[],
|
||||
None,
|
||||
)
|
||||
|
||||
assert response.success is True
|
||||
config.async_set.assert_awaited_once_with(SystemConfigKey.Directories.value, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_non_plugin_config_does_not_resolve_plugin_runtime(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""Agent 非插件配置写入保持既有读写和事件语义。"""
|
||||
config = MagicMock()
|
||||
config.get.side_effect = [{}, {"chatgpt": {"enabled": True}}]
|
||||
config.async_set = AsyncMock(return_value=True)
|
||||
|
||||
def fail_runtime_resolution():
|
||||
"""若 Agent 非插件配置误取插件运行时则立即暴露回归。"""
|
||||
raise AssertionError("非插件配置不应解析插件运行时")
|
||||
|
||||
monkeypatch.setattr(plugin_runtime, "get_plugin_manager", fail_runtime_resolution)
|
||||
monkeypatch.setattr(
|
||||
"app.agent.tools.impl.update_system_settings.eventmanager.async_send_event",
|
||||
AsyncMock(),
|
||||
)
|
||||
tool = UpdateSystemSettingsTool(
|
||||
session_id="session-1",
|
||||
user_id="10001",
|
||||
system_config=config,
|
||||
)
|
||||
|
||||
payload = json.loads(
|
||||
await tool.run(
|
||||
setting_key=SystemConfigKey.AIAgentConfig.value,
|
||||
value={"chatgpt": {"enabled": True}},
|
||||
)
|
||||
)
|
||||
|
||||
assert payload["success"] is True
|
||||
assert payload["changed"] is True
|
||||
config.async_set.assert_awaited_once()
|
||||
Reference in New Issue
Block a user