mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 16:07:01 +08:00
fix: 完善插件恢复与运行态收敛 (#6376)
This commit is contained in:
@@ -1,12 +1,23 @@
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.foundation.singleton import Singleton
|
||||
from app.runtime.extensions.plugin.monitor import PluginMonitorController
|
||||
from app.runtime.extensions.plugin.dependency import (
|
||||
PluginDependencyClassification,
|
||||
PluginDependencyInstallResult,
|
||||
)
|
||||
from app.runtime.extensions.plugin.monitor import (
|
||||
PluginChangeMonitor,
|
||||
PluginMonitorController,
|
||||
)
|
||||
from app.runtime.extensions.plugin.system import reset_plugin_system
|
||||
from app.runtime.extensions.plugin_manager import PluginManager
|
||||
from app.schemas.plugin import PluginRuntimeStatus
|
||||
from app.startup import plugins_initializer
|
||||
|
||||
|
||||
@@ -45,10 +56,15 @@ def test_plugin_manager_constructor_does_not_start_monitor_before_runtime(
|
||||
|
||||
|
||||
def test_init_plugins_starts_monitor_after_runtime_and_routes(monkeypatch) -> None:
|
||||
"""插件运行时和动态路由就绪后,启动层才允许文件监控接收变化。"""
|
||||
"""启动阶段只加载依赖已就绪的插件,再开放路由和文件监控。"""
|
||||
order: list[str] = []
|
||||
manager = MagicMock()
|
||||
manager.start.side_effect = lambda: order.append("plugins")
|
||||
manager.classify_plugins.return_value = PluginDependencyClassification(
|
||||
ready=("ReadyPlugin",),
|
||||
missing_dependencies=("DependencyPending",),
|
||||
missing_source=("SourcePending",),
|
||||
)
|
||||
manager.start.side_effect = lambda plugin_id: order.append(f"plugin:{plugin_id}")
|
||||
manager.start_monitor.side_effect = lambda: order.append("monitor")
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
@@ -64,7 +80,238 @@ def test_init_plugins_starts_monitor_after_runtime_and_routes(monkeypatch) -> No
|
||||
|
||||
plugins_initializer.init_plugins()
|
||||
|
||||
assert order == ["services", "plugins", "routes", "monitor"]
|
||||
assert order == ["services", "plugin:ReadyPlugin", "routes", "monitor"]
|
||||
manager.set_plugin_settling.assert_called_once_with(True)
|
||||
|
||||
|
||||
def test_plugin_manager_projects_dependency_classification_to_runtime_status() -> None:
|
||||
"""真实管理器按分类字段写入三类启动状态,避免测试替身掩盖字段漂移。"""
|
||||
_reset_plugin_manager()
|
||||
manager = PluginManager()
|
||||
|
||||
manager.apply_plugin_dependency_classification(
|
||||
PluginDependencyClassification(
|
||||
ready=("ReadyPlugin",),
|
||||
missing_dependencies=("DependencyPending",),
|
||||
missing_source=("SourcePending",),
|
||||
)
|
||||
)
|
||||
|
||||
assert manager.get_plugin_runtime_statuses() == {
|
||||
"ReadyPlugin": PluginRuntimeStatus.READY,
|
||||
"DependencyPending": PluginRuntimeStatus.DEPENDENCY_PENDING,
|
||||
"SourcePending": PluginRuntimeStatus.SOURCE_MISSING,
|
||||
}
|
||||
_reset_plugin_manager()
|
||||
|
||||
|
||||
def test_plugin_manager_promotes_running_dependency_after_recovery() -> None:
|
||||
"""依赖恢复后,运行中的插件状态必须允许后台流程触发重载。"""
|
||||
_reset_plugin_manager()
|
||||
manager = PluginManager()
|
||||
manager._plugin_registry.running["DependencyRecovered"] = object()
|
||||
manager._plugin_registry.set_runtime_status(
|
||||
"DependencyRecovered",
|
||||
PluginRuntimeStatus.DEPENDENCY_PENDING,
|
||||
)
|
||||
|
||||
manager.apply_plugin_dependency_classification(
|
||||
PluginDependencyClassification(
|
||||
ready=("DependencyRecovered",),
|
||||
missing_dependencies=(),
|
||||
missing_source=(),
|
||||
)
|
||||
)
|
||||
|
||||
assert manager.get_plugin_runtime_statuses()["DependencyRecovered"] is (
|
||||
PluginRuntimeStatus.READY
|
||||
)
|
||||
_reset_plugin_manager()
|
||||
|
||||
|
||||
def _patch_sync_plugins(monkeypatch, manager: MagicMock) -> MagicMock:
|
||||
"""隔离后台执行器并返回动态路由注册替身。"""
|
||||
async def execute(_loop, task_func, _task_name):
|
||||
return task_func()
|
||||
|
||||
register = MagicMock()
|
||||
monkeypatch.setattr(plugins_initializer, "configure_plugin_services", lambda: None)
|
||||
monkeypatch.setattr(plugins_initializer, "PluginManager", lambda: manager)
|
||||
monkeypatch.setattr(plugins_initializer, "execute_task", execute)
|
||||
monkeypatch.setattr(plugins_initializer, "register_plugin_api", register)
|
||||
manager.get_plugin_runtime_statuses.return_value = {}
|
||||
return register
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_plugins_activates_ready_plugins_when_dependencies_fail(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""依赖恢复失败时仍激活无关的已就绪插件。"""
|
||||
manager = MagicMock()
|
||||
manager.sync.return_value = ["demo"]
|
||||
manager.install_plugin_missing_dependencies_with_status.return_value = (
|
||||
PluginDependencyInstallResult(missing=["demo>=1"], success=False)
|
||||
)
|
||||
manager.classify_plugins.return_value = PluginDependencyClassification(
|
||||
ready=("ReadyPlugin",),
|
||||
missing_dependencies=("DependencyPending",),
|
||||
missing_source=(),
|
||||
)
|
||||
manager.running_plugins = {}
|
||||
register = _patch_sync_plugins(monkeypatch, manager)
|
||||
|
||||
assert await plugins_initializer.sync_plugins() is True
|
||||
|
||||
manager.start.assert_called_once_with("ReadyPlugin")
|
||||
manager.reload_plugin.assert_not_called()
|
||||
register.assert_called_once_with("ReadyPlugin")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_plugins_loads_only_plugins_that_become_ready(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""后台依赖恢复后只启动尚未运行且当前已就绪的插件。"""
|
||||
manager = MagicMock()
|
||||
manager.sync.return_value = []
|
||||
manager.install_plugin_missing_dependencies_with_status.return_value = (
|
||||
PluginDependencyInstallResult(missing=["demo>=1"], success=True)
|
||||
)
|
||||
manager.classify_plugins.return_value = PluginDependencyClassification(
|
||||
ready=("ReadyPlugin", "DependencyRecovered"),
|
||||
missing_dependencies=(),
|
||||
missing_source=("SourcePending",),
|
||||
)
|
||||
running = {"ReadyPlugin": object()}
|
||||
manager.running_plugins = running
|
||||
|
||||
def start(plugin_id: str) -> None:
|
||||
running[plugin_id] = object()
|
||||
|
||||
manager.start.side_effect = start
|
||||
register = _patch_sync_plugins(monkeypatch, manager)
|
||||
|
||||
assert await plugins_initializer.sync_plugins() is True
|
||||
|
||||
manager.start.assert_called_once_with("DependencyRecovered")
|
||||
manager.reload_plugin.assert_not_called()
|
||||
register.assert_called_once_with("DependencyRecovered")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_plugins_reloads_only_updated_running_plugins(monkeypatch) -> None:
|
||||
"""源码同步只重载对应运行实例,不重启其他插件。"""
|
||||
manager = MagicMock()
|
||||
manager.sync.return_value = ["UpdatedPlugin"]
|
||||
manager.install_plugin_missing_dependencies_with_status.return_value = (
|
||||
PluginDependencyInstallResult(missing=[], success=True)
|
||||
)
|
||||
manager.classify_plugins.return_value = PluginDependencyClassification(
|
||||
ready=("StablePlugin", "UpdatedPlugin"),
|
||||
missing_dependencies=(),
|
||||
missing_source=(),
|
||||
)
|
||||
manager.running_plugins = {
|
||||
"StablePlugin": object(),
|
||||
"UpdatedPlugin": object(),
|
||||
}
|
||||
register = _patch_sync_plugins(monkeypatch, manager)
|
||||
|
||||
assert await plugins_initializer.sync_plugins() is True
|
||||
|
||||
manager.reload_plugin.assert_called_once_with("UpdatedPlugin")
|
||||
manager.start.assert_not_called()
|
||||
register.assert_called_once_with("UpdatedPlugin")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_plugins_reloads_running_plugin_after_dependency_recovery(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""依赖恢复后,已运行的旧实例必须切换到新源码。"""
|
||||
manager = MagicMock()
|
||||
manager.sync.return_value = []
|
||||
manager.install_plugin_missing_dependencies_with_status.return_value = (
|
||||
PluginDependencyInstallResult(missing=["demo>=1"], success=True)
|
||||
)
|
||||
manager.classify_plugins.return_value = PluginDependencyClassification(
|
||||
ready=("DependencyRecovered",),
|
||||
missing_dependencies=(),
|
||||
missing_source=(),
|
||||
)
|
||||
manager.running_plugins = {"DependencyRecovered": object()}
|
||||
register = _patch_sync_plugins(monkeypatch, manager)
|
||||
manager.get_plugin_runtime_statuses.return_value = {
|
||||
"DependencyRecovered": PluginRuntimeStatus.DEPENDENCY_PENDING,
|
||||
}
|
||||
|
||||
assert await plugins_initializer.sync_plugins() is True
|
||||
|
||||
manager.reload_plugin.assert_called_once_with("DependencyRecovered")
|
||||
manager.start.assert_not_called()
|
||||
register.assert_called_once_with("DependencyRecovered")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_plugins_keeps_runtime_when_nothing_changed(monkeypatch) -> None:
|
||||
"""源码和依赖均无变化时保留首次初始化结果。"""
|
||||
manager = MagicMock()
|
||||
manager.sync.return_value = []
|
||||
manager.install_plugin_missing_dependencies_with_status.return_value = (
|
||||
PluginDependencyInstallResult(missing=[], success=True)
|
||||
)
|
||||
manager.classify_plugins.return_value = PluginDependencyClassification(
|
||||
ready=("ReadyPlugin",),
|
||||
missing_dependencies=(),
|
||||
missing_source=(),
|
||||
)
|
||||
manager.running_plugins = {"ReadyPlugin": object()}
|
||||
register = _patch_sync_plugins(monkeypatch, manager)
|
||||
|
||||
assert await plugins_initializer.sync_plugins() is False
|
||||
|
||||
manager.start.assert_not_called()
|
||||
manager.reload_plugin.assert_not_called()
|
||||
register.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_plugins_keeps_event_loop_responsive_during_activation(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""插件初始化运行在线程池时,Web 事件循环仍可继续调度。"""
|
||||
manager = MagicMock()
|
||||
manager.sync.return_value = []
|
||||
manager.install_plugin_missing_dependencies_with_status.return_value = (
|
||||
PluginDependencyInstallResult(missing=[], success=True)
|
||||
)
|
||||
manager.classify_plugins.return_value = PluginDependencyClassification(
|
||||
ready=("SlowPlugin",),
|
||||
missing_dependencies=(),
|
||||
missing_source=(),
|
||||
)
|
||||
manager.running_plugins = {}
|
||||
activation_started = threading.Event()
|
||||
|
||||
def slow_start(_plugin_id: str) -> None:
|
||||
activation_started.set()
|
||||
time.sleep(0.1)
|
||||
|
||||
manager.start.side_effect = slow_start
|
||||
monkeypatch.setattr(plugins_initializer, "configure_plugin_services", lambda: None)
|
||||
monkeypatch.setattr(plugins_initializer, "PluginManager", lambda: manager)
|
||||
monkeypatch.setattr(plugins_initializer, "register_plugin_api", MagicMock())
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer.global_vars,
|
||||
"CURRENT_EVENT_LOOP",
|
||||
asyncio.get_running_loop(),
|
||||
)
|
||||
|
||||
sync_task = asyncio.create_task(plugins_initializer.sync_plugins())
|
||||
assert await asyncio.to_thread(activation_started.wait, 1)
|
||||
assert sync_task.done() is False
|
||||
assert await sync_task is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -98,6 +345,86 @@ def test_start_monitor_respects_runtime_configuration(
|
||||
_reset_plugin_manager()
|
||||
|
||||
|
||||
def test_plugin_monitor_waits_until_dependency_settlement(monkeypatch) -> None:
|
||||
"""后台依赖收敛期间不启动文件监控,避免源码写入触发重复重载。"""
|
||||
_reset_plugin_manager()
|
||||
reset_plugin_system()
|
||||
monkeypatch.setattr(
|
||||
"app.runtime.extensions.plugin_manager.settings",
|
||||
SimpleNamespace(
|
||||
DEV=True,
|
||||
PLUGIN_AUTO_RELOAD=False,
|
||||
ROOT_PATH=MagicMock(),
|
||||
),
|
||||
)
|
||||
manager = PluginManager()
|
||||
start = MagicMock()
|
||||
reload_monitor = MagicMock()
|
||||
manager._plugin_monitor.start = start
|
||||
manager._plugin_monitor.reload = reload_monitor
|
||||
|
||||
manager.set_plugin_settling(True)
|
||||
manager.start_monitor()
|
||||
manager.reload_monitor()
|
||||
|
||||
start.assert_not_called()
|
||||
reload_monitor.assert_called_once_with(enabled=False)
|
||||
|
||||
manager.set_plugin_settling(False)
|
||||
manager.start_monitor()
|
||||
|
||||
start.assert_called_once_with()
|
||||
_reset_plugin_manager()
|
||||
|
||||
|
||||
def test_plugin_monitor_skips_installing_plugin_until_package_write_finishes(tmp_path) -> None:
|
||||
"""安装替换目录期间,文件事件不得抢先导入未完成的插件包。"""
|
||||
reload_plugin = MagicMock()
|
||||
monitor = PluginChangeMonitor(
|
||||
runtime_root=tmp_path,
|
||||
local_roots=lambda: [],
|
||||
stop_event=threading.Event(),
|
||||
recent_sync={},
|
||||
federated_change=lambda _path: None,
|
||||
runtime_plugin=lambda _path: "DemoPlugin",
|
||||
local_candidate=lambda _path: None,
|
||||
sync_local=MagicMock(),
|
||||
reload_plugin=reload_plugin,
|
||||
dependency_manifest_status=lambda _path: None,
|
||||
watch=lambda *_args, **_kwargs: (),
|
||||
log=MagicMock(),
|
||||
monitor_suppressed=lambda plugin_id: plugin_id.lower() == "demoplugin",
|
||||
)
|
||||
|
||||
monitor._process_changes({("modified", str(tmp_path / "demo" / "plugin.py"))})
|
||||
|
||||
reload_plugin.assert_not_called()
|
||||
|
||||
|
||||
def test_plugin_monitor_suppression_is_reference_counted(monkeypatch) -> None:
|
||||
"""同一插件的重叠写入必须等最后一个事务退出后才解除监控抑制。"""
|
||||
_reset_plugin_manager()
|
||||
reset_plugin_system()
|
||||
monkeypatch.setattr(
|
||||
"app.runtime.extensions.plugin_manager.settings",
|
||||
SimpleNamespace(
|
||||
DEV=False,
|
||||
PLUGIN_AUTO_RELOAD=False,
|
||||
ROOT_PATH=MagicMock(),
|
||||
),
|
||||
)
|
||||
manager = PluginManager()
|
||||
|
||||
with manager.suppress_plugin_monitor("DemoPlugin"):
|
||||
assert manager.is_plugin_monitor_suppressed("demoplugin") is True
|
||||
with manager.suppress_plugin_monitor("demoplugin"):
|
||||
assert manager.is_plugin_monitor_suppressed("DemoPlugin") is True
|
||||
assert manager.is_plugin_monitor_suppressed("DemoPlugin") is True
|
||||
|
||||
assert manager.is_plugin_monitor_suppressed("DemoPlugin") is False
|
||||
_reset_plugin_manager()
|
||||
|
||||
|
||||
def test_config_change_reloads_monitor(monkeypatch) -> None:
|
||||
"""配置热更新继续使用重建语义,不复用首次启动入口。"""
|
||||
_reset_plugin_manager()
|
||||
|
||||
Reference in New Issue
Block a user