Files
MoviePilot/tests/test_plugin_runtime_recovery.py

411 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
插件依赖安装同步、异步入口的恢复决策和资源清理回归。
"""
import asyncio
import threading
from dataclasses import dataclass
from pathlib import Path
import pytest
from app.adapters.system.package import PackageInstallRequest, PackageInstallStrategy
from app.adapters.system.plugin import health as health_module
from app.adapters.system.plugin.health import PluginRuntimeHealth
_HEALTHY = {"核心依赖导入检查": (True, "ok")}
_BROKEN = {"核心依赖导入检查": (False, "new failure")}
_OLD_FAILURE = {"核心依赖导入检查": (False, "old failure")}
_REGRESSION = "核心依赖导入检查失败:new failure"
@dataclass(frozen=True)
class _RecoveryCase:
"""
一次安装在外部边界的固定应答与预期结果。
:param installs: 各安装策略返回的成功标志和输出
:param health: 安装前、安装后及修复后的有序健康快照
:param repairs: 各修复策略返回的成功标志和输出
:param result: 对外返回的完整成功标志和说明
:param events: 必须保持的安装、探测、修复和导入刷新顺序
"""
installs: tuple[tuple[bool, str], ...]
health: tuple[dict[str, tuple[bool, str]], ...]
repairs: tuple[tuple[bool, str], ...]
result: tuple[bool, str]
events: tuple[str, ...]
_CASES = [
pytest.param(_RecoveryCase(
((True, "installed"),), (_HEALTHY, _HEALTHY), (), (True, "installed"),
("health", "install", "health", "refresh"),
), id="healthy"),
pytest.param(_RecoveryCase(
((True, "installed"),), (_OLD_FAILURE, _OLD_FAILURE), (), (True, "installed"),
("health", "install", "health", "refresh"),
), id="existing-failure"),
pytest.param(_RecoveryCase(
((False, "offline"), (True, "installed")), (_HEALTHY, _HEALTHY, _HEALTHY), (),
(True, "installed"), ("health", "install", "health", "install", "health", "refresh"),
), id="fallback"),
pytest.param(_RecoveryCase(
((False, "offline"), (False, "still offline")), (_HEALTHY, _HEALTHY, _HEALTHY), (),
(False, "[UV] 所有策略均安装依赖失败:still offline"),
("health", "install", "health", "install", "health"),
), id="exhausted"),
pytest.param(_RecoveryCase(
((False, ""), (False, "")), (_HEALTHY, _HEALTHY, _HEALTHY), (),
(False, "[UV] 所有策略均安装依赖失败,请检查网络连接、包源配置或插件依赖约束"),
("health", "install", "health", "install", "health"),
), id="empty-errors"),
pytest.param(_RecoveryCase(
((True, "installed"),), (_HEALTHY, _BROKEN, _HEALTHY), ((True, "repaired"),),
(False, f"依赖安装后运行环境自检失败,已自动恢复主程序依赖:{_REGRESSION}"),
("health", "install", "health", "repair", "refresh", "health", "refresh"),
), id="successful-install-restored"),
pytest.param(_RecoveryCase(
((True, "installed"),), (_HEALTHY, _BROKEN), ((False, "repair failed"),),
(False, "依赖安装后运行环境自检失败,且自动恢复主程序依赖失败:repair failed"),
("health", "install", "health", "repair"),
), id="successful-install-repair-failed"),
pytest.param(_RecoveryCase(
((True, "installed"),), (_HEALTHY, _BROKEN, _BROKEN), ((True, "repaired"),),
(False, f"依赖安装后运行环境自检失败,恢复主程序依赖后仍异常:{_REGRESSION}"),
("health", "install", "health", "repair", "refresh", "health"),
), id="successful-install-still-broken"),
pytest.param(_RecoveryCase(
((False, "install failed"),), (_HEALTHY, _BROKEN, _HEALTHY), ((True, "repaired"),),
(False, "策略 install-0 安装依赖失败:install failed;主运行环境已恢复"),
("health", "install", "health", "repair", "refresh", "health"),
), id="failed-install-restored-stops-fallback"),
pytest.param(_RecoveryCase(
((False, "install failed"),), (_HEALTHY, _BROKEN), ((False, "repair failed"),),
(False, f"策略 install-0 安装依赖失败:install failed;插件依赖安装失败后主运行环境异常,且恢复失败:{_REGRESSION}; repair failed"),
("health", "install", "health", "repair"),
), id="failed-install-repair-failed"),
pytest.param(_RecoveryCase(
((False, "install failed"),), (_HEALTHY, _BROKEN, _BROKEN), ((True, "repaired"),),
(False, f"策略 install-0 安装依赖失败:install failed;插件依赖安装失败后主运行环境异常,恢复后仍异常:{_REGRESSION}"),
("health", "install", "health", "repair", "refresh", "health"),
), id="failed-install-still-broken"),
]
@pytest.fixture
def recovery_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""
隔离依赖清单、约束文件、安装锁及子进程,保留真实安装与恢复编排。
:param tmp_path: 本用例独占的临时目录
:param monkeypatch: 在用例结束时撤销边界替换的夹具
:return: 清单路径、约束路径、独占安装锁以及捕获的日志
"""
manifest = tmp_path / "requirements.txt"
manifest.write_text("demo\n", encoding="utf-8")
constraints = tmp_path / "constraints.txt"
lock = threading.Lock()
logs: list[tuple[str, str]] = []
monkeypatch.setattr(PluginRuntimeHealth, "_package_install_lock", lock)
monkeypatch.setattr(health_module, "is_free_threaded_runtime", lambda: False)
monkeypatch.setattr(health_module, "get_runtime_setting", lambda key: {
"ROOT_PATH": tmp_path, "CONFIG_PATH": tmp_path, "TEMP_PATH": tmp_path,
"PACKAGE_CACHE_PATH": tmp_path, "PIP_PROXY": "", "PROXY_HOST": "",
}[key])
monkeypatch.setattr(PluginRuntimeHealth, "_PluginRuntimeHealth__get_installed_packages", lambda: {})
monkeypatch.setattr(PluginRuntimeHealth, "_PluginRuntimeHealth__get_protected_runtime_packages", lambda _: {"fastapi": "1"})
monkeypatch.setattr(PluginRuntimeHealth, "_PluginRuntimeHealth__validate_runtime_dependency_conflicts", lambda *_: (True, ""))
def create_constraints(_packages):
"""
在临时目录生成约束,检查后续路径是否承担清理责任。
"""
constraints.write_text("fastapi==1\n", encoding="utf-8")
return constraints
monkeypatch.setattr(PluginRuntimeHealth, "_PluginRuntimeHealth__create_runtime_constraints_file", create_constraints)
monkeypatch.setattr(health_module.logger, "warning", lambda message: logs.append(("warning", message)))
monkeypatch.setattr(health_module.logger, "error", lambda message: logs.append(("error", message)))
return manifest, constraints, lock, logs
def _patch_runtime(monkeypatch, environment, case: _RecoveryCase) -> list[str]:
"""
在子进程和健康探测边界重放固定应答,并记录持锁期间的操作顺序。
:param monkeypatch: 自动恢复边界方法的夹具
:param environment: 隔离的清单、约束、锁与日志
:param case: 完整应答序列及预期结果
:return: 按真实发生顺序追加的操作列表
"""
_, constraints, lock, _ = environment
events: list[str] = []
installs, repairs, health = iter(case.installs), iter(case.repairs), iter(case.health)
def strategies(request: PackageInstallRequest, dry_run: bool = False) -> list[PackageInstallStrategy]:
"""
返回离线命令,保持真实修复目标和安装策略切换可观察。
"""
assert not dry_run
kind = "repair" if request.purpose == "runtime-repair" else "install"
if kind == "repair":
assert request.dependency_files == (constraints,)
else:
assert request.constraints_file == constraints
return [PackageInstallStrategy(f"{kind}-{index}", [kind], {}, [kind])
for index in range(1 if kind == "repair" else 2)]
def execute(command, **kwargs):
"""
检查安装与恢复持有同一锁,禁止启动真实子进程。
"""
assert lock.locked() and constraints.exists()
assert kwargs.get("timeout") in (None, 300)
kind = command[0]
events.append(kind)
return next(repairs if kind == "repair" else installs)
async def execute_async(command, **kwargs):
"""
复用边界应答,同时要求异步执行保留原有超时。
"""
assert kwargs["timeout"] == 300
return execute(command, **kwargs)
def probe():
"""
重放健康快照,若多探测或少探测则由序列与事件断言识别。
"""
assert lock.locked()
events.append("health")
return next(health)
async def probe_async():
"""
通过相同快照验证异步恢复决策。
"""
return probe()
monkeypatch.setattr(health_module, "build_package_install_strategies", strategies)
monkeypatch.setattr(health_module.SystemUtils, "execute_with_subprocess", execute)
monkeypatch.setattr(health_module.SystemUtils, "execute_with_subprocess_async", execute_async)
monkeypatch.setattr(PluginRuntimeHealth, "_PluginRuntimeHealth__run_runtime_healthcheck", probe)
monkeypatch.setattr(PluginRuntimeHealth, "_PluginRuntimeHealth__async_run_runtime_healthcheck", probe_async)
monkeypatch.setattr(PluginRuntimeHealth, "_PluginRuntimeHealth__refresh_import_system", lambda: events.append("refresh"))
return events
@pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"])
@pytest.mark.parametrize("case", _CASES)
def test_install_recovery_contract(recovery_environment, monkeypatch, case, use_async) -> None:
"""
两种入口保持既有结果、恢复顺序、停止条件及诊断差异。
:param recovery_environment: 隔离的安装环境
:param monkeypatch: 自动恢复子进程和健康检查的夹具
:param case: 参数化的外部应答及预期合同
:param use_async: 是否通过异步公开入口执行
"""
manifest, constraints, lock, logs = recovery_environment
events = _patch_runtime(monkeypatch, recovery_environment, case)
if use_async:
result = asyncio.run(PluginRuntimeHealth().async_install_packages_with_fallback(manifest))
else:
result = PluginRuntimeHealth.install_packages_with_fallback(manifest)
assert result == case.result
assert tuple(events) == case.events
assert not constraints.exists()
assert not lock.locked()
remaining = [message for _, message in logs if "安装前已有的运行环境异常仍然存在" in message]
assert bool(remaining) == (not use_async and case.health == (_OLD_FAILURE, _OLD_FAILURE))
restored_errors = [message for _, message in logs if "主程序依赖恢复后仍未通过健康检查" in message]
assert bool(restored_errors) == (not use_async and case.installs[0][0] and case.health[-1] == _BROKEN and bool(case.repairs) and case.repairs[0][0])
@pytest.mark.parametrize("stage", ["baseline", "install", "post-install", "repair", "post-repair"])
@pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"])
def test_install_exception_releases_resources(recovery_environment, monkeypatch, stage, use_async) -> None:
"""
探测、安装和恢复异常按原样传播,始终释放约束文件与安装锁。
:param recovery_environment: 隔离的安装环境
:param monkeypatch: 自动恢复边界方法的夹具
:param stage: 注入异常的外部操作阶段
:param use_async: 是否执行异步公开入口
"""
manifest, constraints, lock, _ = recovery_environment
case = _RecoveryCase(((True, "ok"),), (_HEALTHY, _BROKEN, _HEALTHY),
((True, "ok"),), (False, ""), ())
_patch_runtime(monkeypatch, recovery_environment, case)
failure = RuntimeError(f"{stage} failed")
probe = PluginRuntimeHealth._PluginRuntimeHealth__run_runtime_healthcheck
execute = health_module.SystemUtils.execute_with_subprocess
phases = iter(("baseline", "post-install", "post-repair"))
def failing_probe():
"""
在指定的健康探测阶段注入同一异常对象。
"""
if next(phases) == stage:
raise failure
return probe()
def failing_execute(command, **kwargs):
"""
在安装或修复子进程边界注入异常。
"""
if command[0] == stage:
raise failure
return execute(command, **kwargs)
async def failing_probe_async():
"""
保持异步探测入口的异常传播行为。
"""
return failing_probe()
async def failing_execute_async(command, **kwargs):
"""
保持异步子进程入口的异常传播行为。
"""
return failing_execute(command, **kwargs)
monkeypatch.setattr(PluginRuntimeHealth, "_PluginRuntimeHealth__run_runtime_healthcheck", failing_probe)
monkeypatch.setattr(PluginRuntimeHealth, "_PluginRuntimeHealth__async_run_runtime_healthcheck", failing_probe_async)
monkeypatch.setattr(health_module.SystemUtils, "execute_with_subprocess", failing_execute)
monkeypatch.setattr(health_module.SystemUtils, "execute_with_subprocess_async", failing_execute_async)
with pytest.raises(RuntimeError) as raised:
if use_async:
asyncio.run(PluginRuntimeHealth().async_install_packages_with_fallback(manifest))
else:
PluginRuntimeHealth.install_packages_with_fallback(manifest)
assert raised.value is failure
assert not constraints.exists()
assert not lock.locked()
@pytest.mark.asyncio
@pytest.mark.parametrize("stage", ["baseline", "install", "post-install", "repair", "post-repair"])
async def test_cancelled_recovery_releases_resources(recovery_environment, monkeypatch, stage) -> None:
"""
在真实等待点取消安装或恢复,取消不得转换成失败结果或继续下一策略。
:param recovery_environment: 隔离的安装环境
:param monkeypatch: 自动恢复子进程和探测的夹具
:param stage: 等待取消的外部操作阶段
"""
manifest, constraints, lock, _ = recovery_environment
case = _RecoveryCase(((True, "ok"),), (_HEALTHY, _BROKEN, _HEALTHY),
((True, "ok"),), (False, ""), ())
events = _patch_runtime(monkeypatch, recovery_environment, case)
probe = PluginRuntimeHealth._PluginRuntimeHealth__run_runtime_healthcheck
execute = health_module.SystemUtils.execute_with_subprocess
phases = iter(("baseline", "post-install", "post-repair"))
entered = asyncio.Event()
exited = asyncio.Event()
async def wait_for_cancel(phase: str) -> None:
"""
使用屏障精确定位取消时点,收尾标记用于验证子操作先退出。
"""
if phase == stage:
entered.set()
try:
await asyncio.Future()
finally:
exited.set()
async def blocked_probe():
"""
在目标健康探测之前暂停。
"""
await wait_for_cancel(next(phases))
return probe()
async def blocked_execute(command, **kwargs):
"""
在目标子进程边界暂停,不启动真实安装器。
"""
await wait_for_cancel(command[0])
return execute(command, **kwargs)
monkeypatch.setattr(PluginRuntimeHealth, "_PluginRuntimeHealth__async_run_runtime_healthcheck", blocked_probe)
monkeypatch.setattr(health_module.SystemUtils, "execute_with_subprocess_async", blocked_execute)
task = asyncio.create_task(PluginRuntimeHealth().async_install_packages_with_fallback(manifest))
try:
await asyncio.wait_for(entered.wait(), timeout=5)
before_cancel = list(events)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(task, timeout=5)
assert exited.is_set()
assert events == before_cancel
assert not constraints.exists()
assert not lock.locked()
finally:
if not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
@pytest.mark.asyncio
async def test_cancelled_install_waiting_for_lock_preserves_owner(recovery_environment, monkeypatch) -> None:
"""
排队时取消只清理自己的约束,不能释放其他安装者持有的锁。
:param recovery_environment: 隔离的安装环境
:param monkeypatch: 自动恢复预检边界的夹具
"""
manifest, constraints, lock, _ = recovery_environment
ready = asyncio.Event()
async def preflight(_request):
"""
在创建约束之后标记就绪,下一步将等待已被占用的锁。
"""
ready.set()
return None
monkeypatch.setattr(health_module, "build_package_install_strategies", lambda _request: [])
monkeypatch.setattr(PluginRuntimeHealth, "_PluginRuntimeHealth__async_free_threaded_preflight", preflight)
lock.acquire()
task = asyncio.create_task(PluginRuntimeHealth().async_install_packages_with_fallback(manifest))
try:
await asyncio.wait_for(ready.wait(), timeout=5)
assert constraints.exists()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(task, timeout=5)
assert lock.locked()
assert not constraints.exists()
finally:
lock.release()
if not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
@pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"])
def test_empty_install_strategies_preserve_failure(recovery_environment, monkeypatch, use_async) -> None:
"""
无可用策略时保留原有失败说明,不启动安装或恢复。
:param recovery_environment: 隔离的安装环境
:param monkeypatch: 自动恢复策略工厂和边界方法的夹具
:param use_async: 是否执行异步公开入口
"""
manifest, constraints, lock, _ = recovery_environment
case = _RecoveryCase((), (_HEALTHY,), (), (False, ""), ())
events = _patch_runtime(monkeypatch, recovery_environment, case)
monkeypatch.setattr(health_module, "build_package_install_strategies", lambda _request: [])
if use_async:
result = asyncio.run(PluginRuntimeHealth().async_install_packages_with_fallback(manifest))
else:
result = PluginRuntimeHealth.install_packages_with_fallback(manifest)
assert result == (False, "[UV] 所有策略均安装依赖失败,请检查网络连接、包源配置或插件依赖约束")
assert events == ["health"]
assert not constraints.exists()
assert not lock.locked()