mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 16:07:01 +08:00
fix(plugin): make installation lifecycle cancellable
This commit is contained in:
+53
-22
@@ -9,7 +9,7 @@ import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -1615,39 +1615,70 @@ demo = { index = "private" }
|
||||
assert env["HTTPS_PROXY"] == "http://proxy.example:7890"
|
||||
assert "user:pass" not in " ".join(safe_command)
|
||||
|
||||
def test_async_package_install_runs_in_threadpool(self):
|
||||
"""
|
||||
验证异步安装路径会把同步包安装派发到线程池,避免阻塞事件循环。
|
||||
"""
|
||||
def test_async_package_install_uses_cancellable_subprocess(self):
|
||||
"""异步依赖安装应直接使用可取消的子进程执行器。"""
|
||||
try:
|
||||
from app.adapters.external.market import PluginHelper
|
||||
except ModuleNotFoundError as exc:
|
||||
pytest.skip(f"missing dependency: {exc}")
|
||||
|
||||
helper = PluginHelper()
|
||||
requirements_file = Path("/tmp/demo-requirements.txt")
|
||||
find_links_dirs = [Path("/tmp/demo-wheels")]
|
||||
calls = []
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
requirements_file = Path(temp_dir) / "demo-requirements.txt"
|
||||
requirements_file.write_text("demo-package\n", encoding="utf-8")
|
||||
find_links_dirs = [Path(temp_dir) / "wheels"]
|
||||
|
||||
async def run_install():
|
||||
return await helper._PluginHelper__async_install_packages_with_fallback(
|
||||
requirements_file,
|
||||
find_links_dirs
|
||||
async def run_install():
|
||||
return await helper._PluginHelper__async_install_packages_with_fallback(
|
||||
requirements_file,
|
||||
find_links_dirs,
|
||||
)
|
||||
|
||||
health = {
|
||||
"uv check": (True, "ok"),
|
||||
"核心依赖导入检查": (True, "ok"),
|
||||
}
|
||||
strategy = Mock(
|
||||
strategy_name="uv:test",
|
||||
command=["uv", "pip", "install"],
|
||||
env={},
|
||||
safe_log_command=["uv", "pip", "install"],
|
||||
)
|
||||
|
||||
async def fake_to_thread(func, *args, **kwargs):
|
||||
calls.append((func, args, kwargs))
|
||||
return True, "ok"
|
||||
|
||||
with patch("app.adapters.external.market.asyncio.to_thread", side_effect=fake_to_thread):
|
||||
success, message = asyncio.run(run_install())
|
||||
with patch.object(
|
||||
PluginHelper,
|
||||
"_PluginHelper__get_installed_packages",
|
||||
return_value={},
|
||||
), patch.object(
|
||||
PluginHelper,
|
||||
"_PluginHelper__get_protected_runtime_packages",
|
||||
return_value={},
|
||||
), patch.object(
|
||||
PluginHelper,
|
||||
"_PluginHelper__validate_runtime_dependency_conflicts",
|
||||
return_value=(True, ""),
|
||||
), patch(
|
||||
"app.adapters.external.market.build_package_install_strategies",
|
||||
return_value=[strategy],
|
||||
), patch.object(
|
||||
PluginHelper,
|
||||
"_PluginHelper__async_run_runtime_healthcheck",
|
||||
side_effect=[health, health],
|
||||
), patch.object(
|
||||
PluginHelper,
|
||||
"_PluginHelper__refresh_import_system",
|
||||
), patch(
|
||||
"app.adapters.external.market.SystemUtils.execute_with_subprocess_async",
|
||||
new=AsyncMock(return_value=(True, "ok")),
|
||||
) as execute_mock:
|
||||
success, message = asyncio.run(run_install())
|
||||
|
||||
assert success
|
||||
assert "ok" == message
|
||||
assert 1 == len(calls)
|
||||
assert helper.install_packages_with_fallback == calls[0][0]
|
||||
assert (requirements_file, find_links_dirs) == calls[0][1]
|
||||
assert {} == calls[0][2]
|
||||
execute_mock.assert_awaited_once()
|
||||
assert execute_mock.await_args.kwargs["timeout"] == (
|
||||
PluginHelper.PLUGIN_DEPENDENCY_INSTALL_TIMEOUT
|
||||
)
|
||||
|
||||
def test_install_uses_release_package_when_asset_is_available(self, monkeypatch):
|
||||
"""
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
@@ -306,6 +307,96 @@ async def test_registration_failure_restores_instance_files_and_routes() -> None
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_plugin_install_lifecycle_is_serialized() -> None:
|
||||
"""同一插件的两个安装调用不得同时修改包、运行态和注册信息。"""
|
||||
first_started = asyncio.Event()
|
||||
release_first = asyncio.Event()
|
||||
calls: list[str] = []
|
||||
|
||||
async def install(plugin_id, *_args):
|
||||
calls.append(plugin_id)
|
||||
if len(calls) == 1:
|
||||
first_started.set()
|
||||
await release_first.wait()
|
||||
return True, "ok"
|
||||
|
||||
command = _command(installer=install)
|
||||
first = asyncio.create_task(
|
||||
command.execute(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="https://github.com/demo/plugins",
|
||||
)
|
||||
)
|
||||
await first_started.wait()
|
||||
second = asyncio.create_task(
|
||||
command.execute(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="https://github.com/demo/plugins",
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0.02)
|
||||
assert calls == ["DemoPlugin"]
|
||||
|
||||
release_first.set()
|
||||
results = await asyncio.gather(first, second)
|
||||
assert all(result.success for result in results)
|
||||
assert calls == ["DemoPlugin", "DemoPlugin"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_install_waits_for_rollback_before_releasing_lifecycle() -> None:
|
||||
"""取消安装后先完成包快照补偿,再允许同一插件的新调用进入。"""
|
||||
install_started = asyncio.Event()
|
||||
release_install = asyncio.Event()
|
||||
rollback = AsyncMock()
|
||||
|
||||
async def install(*_args):
|
||||
install_started.set()
|
||||
await release_install.wait()
|
||||
return True, "ok"
|
||||
|
||||
command = _command(installer=install, rollback=rollback)
|
||||
task = asyncio.create_task(
|
||||
command.execute(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="https://github.com/demo/plugins",
|
||||
)
|
||||
)
|
||||
await install_started.wait()
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
rollback.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_lifecycle_lock_blocks_plugin_install_until_settlement() -> None:
|
||||
"""启动同步持有全局资格时,插件安装不得穿过启动收口。"""
|
||||
from app.application.plugin.lifecycle import plugin_lifecycle
|
||||
|
||||
entered = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def startup_scope():
|
||||
async with plugin_lifecycle.hold_startup():
|
||||
entered.set()
|
||||
await release.wait()
|
||||
|
||||
startup = asyncio.create_task(startup_scope())
|
||||
await entered.wait()
|
||||
plugin_context = plugin_lifecycle.hold("DemoPlugin")
|
||||
plugin_scope = asyncio.create_task(plugin_context.__aenter__())
|
||||
await asyncio.sleep(0.02)
|
||||
assert plugin_scope.done() is False
|
||||
|
||||
release.set()
|
||||
await plugin_scope
|
||||
await plugin_context.__aexit__(None, None, None)
|
||||
await startup
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_report_failure_does_not_rollback_completed_local_install():
|
||||
"""统计上报失败属于非关键副作用,不得撤销已成功的本地安装。"""
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import asyncio
|
||||
import errno
|
||||
import itertools
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest import TestCase
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
@@ -155,6 +158,53 @@ def test_execute_with_subprocess_uses_safe_command_in_failure_message():
|
||||
assert run_mock.call_args.args[0] == command
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_subprocess_timeout_reaps_process():
|
||||
"""异步安装命令超时后应终止并回收子进程。"""
|
||||
success, message = await SystemUtils.execute_with_subprocess_async(
|
||||
[sys.executable, "-c", "import time; time.sleep(60)"],
|
||||
timeout=0.05,
|
||||
)
|
||||
|
||||
assert success is False
|
||||
assert "执行超时" in message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_subprocess_cancellation_reaps_process(tmp_path):
|
||||
"""调用方取消安装任务时,底层子进程不得继续运行。"""
|
||||
marker = tmp_path / "pid"
|
||||
command = [
|
||||
sys.executable,
|
||||
"-c",
|
||||
(
|
||||
"from pathlib import Path; import os, time; "
|
||||
f"Path({str(marker)!r}).write_text(str(os.getpid())); time.sleep(60)"
|
||||
),
|
||||
]
|
||||
task = asyncio.create_task(
|
||||
SystemUtils.execute_with_subprocess_async(command, timeout=30)
|
||||
)
|
||||
deadline = time.monotonic() + 2
|
||||
while not marker.exists() and time.monotonic() < deadline:
|
||||
await asyncio.sleep(0.01)
|
||||
assert marker.exists()
|
||||
|
||||
pid = int(marker.read_text())
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
for _ in range(100):
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
else:
|
||||
pytest.fail(f"子进程仍在运行:{pid}")
|
||||
|
||||
|
||||
def test_execute_with_subprocess_redacts_userinfo_from_stdout_and_stderr():
|
||||
error = subprocess.CalledProcessError(
|
||||
returncode=1,
|
||||
|
||||
Reference in New Issue
Block a user