fix(plugin): make installation lifecycle cancellable

This commit is contained in:
InfinityPacer
2026-08-23 14:26:42 +08:00
parent b1ad309fc7
commit 0378eabb8f
10 changed files with 932 additions and 82 deletions
+53 -22
View File
@@ -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):
"""
+91
View File
@@ -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():
"""统计上报失败属于非关键副作用,不得撤销已成功的本地安装。"""
+50
View File
@@ -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,