mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
Merge remote-tracking branch 'origin/v3' into v3
# Conflicts: # tests/fixtures/architecture/dependency-baseline.json
This commit is contained in:
+16
-8
@@ -13,8 +13,8 @@
|
||||
"runtime_to_db": [],
|
||||
"workflow_to_db": []
|
||||
},
|
||||
"edge_count": 6463,
|
||||
"edge_sha256": "086c14fef27199ae6f19fbe5e6a07c348cd1dc8a406ab3129a341647fd90a28f",
|
||||
"edge_count": 6470,
|
||||
"edge_sha256": "571b66b75b51e801a053e4164c5dbe0eb580b91f20cf84fa554e13b9469dd650",
|
||||
"edges": [
|
||||
"app -> app.runtime",
|
||||
"app -> app.runtime.compat",
|
||||
@@ -2062,7 +2062,6 @@
|
||||
"app.api.endpoints.plugin -> app.application",
|
||||
"app.api.endpoints.plugin -> app.application.commands",
|
||||
"app.api.endpoints.plugin -> app.application.configuration",
|
||||
"app.api.endpoints.plugin -> app.application.database",
|
||||
"app.api.endpoints.plugin -> app.application.plugin",
|
||||
"app.api.endpoints.plugin -> app.application.plugin.config",
|
||||
"app.api.endpoints.plugin -> app.application.plugin.folders",
|
||||
@@ -2079,6 +2078,7 @@
|
||||
"app.api.endpoints.plugin -> app.runtime.tasks",
|
||||
"app.api.endpoints.plugin -> app.schemas",
|
||||
"app.api.endpoints.plugin -> app.schemas.common",
|
||||
"app.api.endpoints.plugin -> app.schemas.exception",
|
||||
"app.api.endpoints.plugin -> app.schemas.plugin",
|
||||
"app.api.endpoints.plugin -> app.schemas.response",
|
||||
"app.api.endpoints.plugin -> app.schemas.token",
|
||||
@@ -2579,6 +2579,7 @@
|
||||
"app.application.messaging.chat -> app.runtime.observability",
|
||||
"app.application.messaging.chat -> app.schemas",
|
||||
"app.application.messaging.chat -> app.schemas.agent",
|
||||
"app.application.messaging.chat -> app.schemas.exception",
|
||||
"app.application.messaging.interaction -> app.schemas",
|
||||
"app.application.messaging.interaction -> app.schemas.message",
|
||||
"app.application.messaging.interaction -> app.schemas.notification",
|
||||
@@ -2665,7 +2666,12 @@
|
||||
"app.application.plugin.folders -> app.schemas",
|
||||
"app.application.plugin.folders -> app.schemas.types",
|
||||
"app.application.plugin.install -> app.application",
|
||||
"app.application.plugin.install -> app.application.database",
|
||||
"app.application.plugin.install -> app.application.plugin",
|
||||
"app.application.plugin.install -> app.application.plugin.lifecycle",
|
||||
"app.application.plugin.install -> app.runtime",
|
||||
"app.application.plugin.install -> app.runtime.log",
|
||||
"app.application.plugin.install -> app.schemas",
|
||||
"app.application.plugin.install -> app.schemas.exception",
|
||||
"app.application.recognition -> app.application",
|
||||
"app.application.recognition -> app.application.configuration",
|
||||
"app.application.recognition -> app.schemas",
|
||||
@@ -3694,10 +3700,10 @@
|
||||
"app.db.session -> app.runtime.config",
|
||||
"app.db.session -> app.runtime.log",
|
||||
"app.db.session -> app.runtime.observability",
|
||||
"app.db.worker -> app.application",
|
||||
"app.db.worker -> app.application.database",
|
||||
"app.db.worker -> app.runtime",
|
||||
"app.db.worker -> app.runtime.observability",
|
||||
"app.db.worker -> app.schemas",
|
||||
"app.db.worker -> app.schemas.exception",
|
||||
"app.doctor.checks -> app.adapters",
|
||||
"app.doctor.checks -> app.adapters.system",
|
||||
"app.doctor.checks -> app.adapters.system.backup",
|
||||
@@ -3821,7 +3827,6 @@
|
||||
"app.factory -> app.api",
|
||||
"app.factory -> app.api.response",
|
||||
"app.factory -> app.application",
|
||||
"app.factory -> app.application.database",
|
||||
"app.factory -> app.application.plugin",
|
||||
"app.factory -> app.application.plugin.routes",
|
||||
"app.factory -> app.application.security",
|
||||
@@ -3835,6 +3840,7 @@
|
||||
"app.factory -> app.runtime.observability",
|
||||
"app.factory -> app.runtime.settings",
|
||||
"app.factory -> app.schemas",
|
||||
"app.factory -> app.schemas.exception",
|
||||
"app.factory -> app.schemas.mcp",
|
||||
"app.factory -> app.schemas.openai",
|
||||
"app.factory -> app.schemas.response",
|
||||
@@ -6056,6 +6062,7 @@
|
||||
"app.startup.lifecycle -> app.adapters.network.http",
|
||||
"app.startup.lifecycle -> app.application",
|
||||
"app.startup.lifecycle -> app.application.plugin",
|
||||
"app.startup.lifecycle -> app.application.plugin.lifecycle",
|
||||
"app.startup.lifecycle -> app.application.plugin.runtime",
|
||||
"app.startup.lifecycle -> app.chain",
|
||||
"app.startup.lifecycle -> app.chain.system",
|
||||
@@ -6480,7 +6487,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 799,
|
||||
"module_count": 800,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -6772,6 +6779,7 @@
|
||||
"app.application.plugin.data",
|
||||
"app.application.plugin.folders",
|
||||
"app.application.plugin.install",
|
||||
"app.application.plugin.lifecycle",
|
||||
"app.application.plugin.routes",
|
||||
"app.application.plugin.runtime",
|
||||
"app.application.recognition",
|
||||
|
||||
@@ -11,7 +11,7 @@ from uuid import uuid4
|
||||
import pytest
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.application.database import (
|
||||
from app.schemas.exception import (
|
||||
DatabaseWorkerClosedError,
|
||||
DatabaseWorkerOverloadedError,
|
||||
)
|
||||
|
||||
@@ -23,7 +23,7 @@ from app.factory import (
|
||||
localized_unhandled_exception_handler,
|
||||
localized_validation_exception_handler,
|
||||
)
|
||||
from app.application.database import (
|
||||
from app.schemas.exception import (
|
||||
DatabaseWorkerClosedError,
|
||||
DatabaseWorkerOverloadedError,
|
||||
)
|
||||
|
||||
@@ -6,8 +6,8 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.worker import (
|
||||
DatabaseWorker,
|
||||
from app.db.worker import DatabaseWorker
|
||||
from app.schemas.exception import (
|
||||
DatabaseWorkerClosedError,
|
||||
DatabaseWorkerOverloadedError,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
from unittest.mock import AsyncMock
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.runtime.extensions.plugin.dependency import PluginDependencyService
|
||||
import pytest
|
||||
|
||||
from app.runtime.extensions.plugin.dependency import (
|
||||
PluginDependencyInstallResult,
|
||||
PluginDependencyService,
|
||||
)
|
||||
|
||||
|
||||
def test_install_missing_skips_installer_when_environment_is_satisfied() -> None:
|
||||
@@ -35,3 +41,25 @@ def test_install_missing_preserves_list_return_contract() -> None:
|
||||
|
||||
assert service.install_missing() == ["demo>=1"]
|
||||
installer.install.assert_called_once_with(["demo>=1"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_install_missing_uses_async_installer() -> None:
|
||||
"""异步启动恢复必须调用可取消的依赖安装入口。"""
|
||||
installer = SimpleNamespace(
|
||||
async_find_missing=AsyncMock(return_value=["demo>=1"]),
|
||||
async_install=AsyncMock(return_value=(True, "")),
|
||||
)
|
||||
service = PluginDependencyService(
|
||||
system=lambda: SimpleNamespace(dependency=installer),
|
||||
log=MagicMock(),
|
||||
)
|
||||
|
||||
result = await service.async_install_missing_with_status()
|
||||
|
||||
assert result == PluginDependencyInstallResult(
|
||||
missing=["demo>=1"],
|
||||
success=True,
|
||||
)
|
||||
installer.async_find_missing.assert_awaited_once()
|
||||
installer.async_install.assert_awaited_once_with(["demo>=1"])
|
||||
|
||||
+234
-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,251 @@ 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_async_package_install_cancellation_closes_full_lifecycle(self, tmp_path):
|
||||
"""取消真实安装进程后必须回收进程树、临时约束和安装锁。"""
|
||||
import psutil
|
||||
|
||||
from app.adapters.external.market import PluginHelper
|
||||
|
||||
helper = PluginHelper()
|
||||
requirements_file = tmp_path / "requirements.txt"
|
||||
requirements_file.write_text("demo-package\n", encoding="utf-8")
|
||||
constraints_file = tmp_path / "runtime-constraints.txt"
|
||||
marker = tmp_path / "install-pids"
|
||||
child_code = "import time; time.sleep(60)"
|
||||
install_code = (
|
||||
"from pathlib import Path; import os, subprocess, time; "
|
||||
f"child = subprocess.Popen([{sys.executable!r}, '-c', {child_code!r}]); "
|
||||
f"Path({str(marker)!r}).write_text(str(os.getpid()) + ':' + str(child.pid)); "
|
||||
"time.sleep(60)"
|
||||
)
|
||||
strategy = Mock(
|
||||
strategy_name="uv:test",
|
||||
command=[sys.executable, "-c", install_code],
|
||||
env=os.environ.copy(),
|
||||
safe_log_command=[sys.executable, "-c", "<install>"],
|
||||
)
|
||||
health = {
|
||||
"uv check": (True, "ok"),
|
||||
"核心依赖导入检查": (True, "ok"),
|
||||
}
|
||||
|
||||
def create_constraints(_protected_packages):
|
||||
constraints_file.write_text("fastapi==0\n", encoding="utf-8")
|
||||
return constraints_file
|
||||
|
||||
async def run_install():
|
||||
task = asyncio.create_task(
|
||||
helper.async_install_packages_with_fallback(requirements_file)
|
||||
)
|
||||
deadline = time.monotonic() + 2
|
||||
while not marker.exists() and time.monotonic() < deadline:
|
||||
await asyncio.sleep(0.01)
|
||||
assert marker.exists()
|
||||
|
||||
pids = [int(value) for value in marker.read_text().split(":")]
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert not constraints_file.exists()
|
||||
assert PluginHelper._package_install_lock.acquire(blocking=False)
|
||||
PluginHelper._package_install_lock.release()
|
||||
for _ in range(100):
|
||||
alive = []
|
||||
for pid in pids:
|
||||
try:
|
||||
process = psutil.Process(pid)
|
||||
if (
|
||||
process.is_running()
|
||||
and process.status() != psutil.STATUS_ZOMBIE
|
||||
):
|
||||
alive.append(pid)
|
||||
except (psutil.Error, OSError):
|
||||
continue
|
||||
if not alive:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
else:
|
||||
pytest.fail(f"安装进程树仍在运行:{alive}")
|
||||
|
||||
with patch.object(
|
||||
PluginHelper,
|
||||
"_PluginHelper__get_installed_packages",
|
||||
return_value={},
|
||||
), patch.object(
|
||||
PluginHelper,
|
||||
"_PluginHelper__get_protected_runtime_packages",
|
||||
return_value={"fastapi": "0"},
|
||||
), patch.object(
|
||||
PluginHelper,
|
||||
"_PluginHelper__validate_runtime_dependency_conflicts",
|
||||
return_value=(True, ""),
|
||||
), patch.object(
|
||||
PluginHelper,
|
||||
"_PluginHelper__create_runtime_constraints_file",
|
||||
side_effect=create_constraints,
|
||||
), patch(
|
||||
"app.adapters.external.market.build_package_install_strategies",
|
||||
return_value=[strategy],
|
||||
), patch.object(
|
||||
PluginHelper,
|
||||
"_PluginHelper__async_run_runtime_healthcheck",
|
||||
new=AsyncMock(return_value=health),
|
||||
):
|
||||
asyncio.run(run_install())
|
||||
|
||||
def test_constraints_created_during_cancellation_are_removed(self, tmp_path):
|
||||
"""约束文件创建线程收口后仍须响应取消并删除临时文件。"""
|
||||
from app.adapters.external.market import PluginHelper
|
||||
|
||||
helper = PluginHelper()
|
||||
requirements_file = tmp_path / "requirements.txt"
|
||||
requirements_file.write_text("demo-package\n", encoding="utf-8")
|
||||
constraints_file = tmp_path / "runtime-constraints.txt"
|
||||
created = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def create_constraints(_protected_packages):
|
||||
constraints_file.write_text("fastapi==0\n", encoding="utf-8")
|
||||
created.set()
|
||||
release.wait(timeout=2)
|
||||
return constraints_file
|
||||
|
||||
async def run_install():
|
||||
task = asyncio.create_task(
|
||||
helper.async_install_packages_with_fallback(requirements_file)
|
||||
)
|
||||
assert await asyncio.to_thread(created.wait, 2)
|
||||
task.cancel()
|
||||
release.set()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
with patch.object(
|
||||
PluginHelper,
|
||||
"_PluginHelper__get_installed_packages",
|
||||
return_value={},
|
||||
), patch.object(
|
||||
PluginHelper,
|
||||
"_PluginHelper__get_protected_runtime_packages",
|
||||
return_value={"fastapi": "0"},
|
||||
), patch.object(
|
||||
PluginHelper,
|
||||
"_PluginHelper__validate_runtime_dependency_conflicts",
|
||||
return_value=(True, ""),
|
||||
), patch.object(
|
||||
PluginHelper,
|
||||
"_PluginHelper__create_runtime_constraints_file",
|
||||
side_effect=create_constraints,
|
||||
):
|
||||
asyncio.run(run_install())
|
||||
|
||||
assert not constraints_file.exists()
|
||||
|
||||
def test_constraints_cleanup_failure_preserves_cancellation(self, tmp_path):
|
||||
"""临时文件删除失败只记录日志,不得替换调用方的取消异常。"""
|
||||
from app.adapters.external.market import PluginHelper
|
||||
|
||||
constraints_file = tmp_path / "runtime-constraints.txt"
|
||||
created = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def create_constraints(_protected_packages):
|
||||
constraints_file.write_text("fastapi==0\n", encoding="utf-8")
|
||||
created.set()
|
||||
release.wait(timeout=2)
|
||||
return constraints_file
|
||||
|
||||
async def run_create():
|
||||
task = asyncio.create_task(
|
||||
PluginHelper._PluginHelper__async_create_runtime_constraints_file(
|
||||
{"fastapi": Version("0")}
|
||||
)
|
||||
)
|
||||
assert await asyncio.to_thread(created.wait, 2)
|
||||
task.cancel()
|
||||
release.set()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
with patch.object(
|
||||
PluginHelper,
|
||||
"_PluginHelper__create_runtime_constraints_file",
|
||||
side_effect=create_constraints,
|
||||
), patch.object(
|
||||
Path,
|
||||
"unlink",
|
||||
side_effect=PermissionError("locked"),
|
||||
), patch("app.adapters.external.market.logger.warning") as warning:
|
||||
asyncio.run(run_create())
|
||||
|
||||
warning.assert_called_once()
|
||||
|
||||
def test_install_uses_release_package_when_asset_is_available(self, monkeypatch):
|
||||
"""
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.database import DatabaseWorkerOverloadedError
|
||||
from app.schemas.exception import DatabaseWorkerOverloadedError
|
||||
from app.application.plugin.install import PluginInstallCommand
|
||||
|
||||
|
||||
@@ -155,6 +156,42 @@ async def test_existing_plugin_checks_compatibility_without_reinstalling_package
|
||||
checkpointer.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_existing_plugin_refresh_restores_runtime_and_registrations():
|
||||
"""已存在插件刷新被取消时,必须重新收敛运行态和注册。"""
|
||||
registration_started = asyncio.Event()
|
||||
calls: list[str] = []
|
||||
|
||||
async def reload_plugin(_plugin_id: str) -> None:
|
||||
calls.append("reload")
|
||||
|
||||
async def refresh_registrations(_plugin_id: str) -> None:
|
||||
calls.append("registrations")
|
||||
if calls.count("registrations") == 1:
|
||||
registration_started.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
with patch("app.application.plugin.install.logger.warning") as warning:
|
||||
task = asyncio.create_task(
|
||||
_command(
|
||||
installed=["DemoPlugin"],
|
||||
plugin_ids=["DemoPlugin"],
|
||||
reloader=reload_plugin,
|
||||
refresher=refresh_registrations,
|
||||
).execute(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="https://github.com/demo/plugins",
|
||||
)
|
||||
)
|
||||
await registration_started.wait()
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert calls == ["reload", "registrations", "reload", "registrations"]
|
||||
warning.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persistence_failure_restores_package_without_touching_runtime():
|
||||
"""已安装列表保存失败时恢复文件,且运行态尚未开始切换。"""
|
||||
@@ -176,12 +213,41 @@ async def test_persistence_failure_restores_package_without_touching_runtime():
|
||||
assert result.success is False
|
||||
assert result.failure_stage == "installed_list_persistence"
|
||||
assert result.rollback.file_restored is True
|
||||
assert result.rollback.installed_list_attempted is False
|
||||
assert result.rollback.installed_list_attempted is True
|
||||
assert result.rollback.runtime_attempted is False
|
||||
rollback.assert_awaited_once_with(checkpoint)
|
||||
reloader.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persistence_exception_after_write_restores_installed_list():
|
||||
"""清单写入已提交后抛异常时,文件和清单必须一起恢复。"""
|
||||
persisted: list[list[str]] = []
|
||||
checkpoint = object()
|
||||
rollback = AsyncMock()
|
||||
|
||||
async def write(plugin_ids: list[str]) -> None:
|
||||
persisted.append(list(plugin_ids))
|
||||
if len(persisted) == 1:
|
||||
raise RuntimeError("write acknowledgement lost")
|
||||
|
||||
result = await _command(
|
||||
checkpointer=AsyncMock(return_value=checkpoint),
|
||||
writer=write,
|
||||
rollback=rollback,
|
||||
).execute(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="https://github.com/demo/plugins",
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert result.failure_stage == "installed_list_persistence"
|
||||
assert result.rollback.installed_list_attempted is True
|
||||
assert result.rollback.installed_list_restored is True
|
||||
assert persisted == [["DemoPlugin"], []]
|
||||
rollback.assert_awaited_once_with(checkpoint)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_worker_overload_rolls_back_and_reaches_api_boundary():
|
||||
"""配置 worker 背压完成补偿后继续抛出,交由 API 映射为 503。"""
|
||||
@@ -306,6 +372,148 @@ 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_cancelled_persisted_list_is_restored_conservatively() -> None:
|
||||
"""清单写入已产生副作用但尚未返回时取消,也必须恢复原清单。"""
|
||||
persisted: list[list[str]] = []
|
||||
writer_started = asyncio.Event()
|
||||
rollback = AsyncMock()
|
||||
|
||||
async def writer(plugin_ids: list[str]) -> None:
|
||||
persisted.append(list(plugin_ids))
|
||||
if len(persisted) == 1:
|
||||
writer_started.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
task = asyncio.create_task(
|
||||
_command(writer=writer, rollback=rollback).execute(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="https://github.com/demo/plugins",
|
||||
)
|
||||
)
|
||||
await writer_started.wait()
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert persisted == [["DemoPlugin"], []]
|
||||
rollback.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_snapshot_cleanup_does_not_rollback_committed_plugin() -> None:
|
||||
"""运行态提交后清理快照期间取消,不得删除已生效插件。"""
|
||||
cleanup_started = asyncio.Event()
|
||||
rollback = AsyncMock()
|
||||
|
||||
async def committer(_checkpoint) -> None:
|
||||
cleanup_started.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
task = asyncio.create_task(
|
||||
_command(committer=committer, rollback=rollback).execute(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="https://github.com/demo/plugins",
|
||||
)
|
||||
)
|
||||
await cleanup_started.wait()
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
rollback.assert_not_awaited()
|
||||
|
||||
|
||||
@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():
|
||||
"""统计上报失败属于非关键副作用,不得撤销已成功的本地安装。"""
|
||||
|
||||
@@ -2,7 +2,7 @@ import asyncio
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -139,6 +139,12 @@ def _patch_sync_plugins(monkeypatch, manager: MagicMock) -> MagicMock:
|
||||
monkeypatch.setattr(plugins_initializer, "PluginManager", lambda: manager)
|
||||
monkeypatch.setattr(plugins_initializer, "execute_task", execute)
|
||||
monkeypatch.setattr(plugins_initializer, "register_plugin_api", register)
|
||||
dependency_result = (
|
||||
manager.async_install_plugin_missing_dependencies_with_status.return_value
|
||||
)
|
||||
manager.async_install_plugin_missing_dependencies_with_status = AsyncMock(
|
||||
return_value=dependency_result,
|
||||
)
|
||||
manager.get_plugin_runtime_statuses.return_value = {}
|
||||
return register
|
||||
|
||||
@@ -150,7 +156,7 @@ async def test_sync_plugins_activates_ready_plugins_when_dependencies_fail(
|
||||
"""依赖恢复失败时仍激活无关的已就绪插件。"""
|
||||
manager = MagicMock()
|
||||
manager.sync.return_value = ["demo"]
|
||||
manager.install_plugin_missing_dependencies_with_status.return_value = (
|
||||
manager.async_install_plugin_missing_dependencies_with_status.return_value = (
|
||||
PluginDependencyInstallResult(missing=["demo>=1"], success=False)
|
||||
)
|
||||
manager.classify_plugins.return_value = PluginDependencyClassification(
|
||||
@@ -175,7 +181,7 @@ async def test_sync_plugins_loads_only_plugins_that_become_ready(
|
||||
"""后台依赖恢复后只启动尚未运行且当前已就绪的插件。"""
|
||||
manager = MagicMock()
|
||||
manager.sync.return_value = []
|
||||
manager.install_plugin_missing_dependencies_with_status.return_value = (
|
||||
manager.async_install_plugin_missing_dependencies_with_status.return_value = (
|
||||
PluginDependencyInstallResult(missing=["demo>=1"], success=True)
|
||||
)
|
||||
manager.classify_plugins.return_value = PluginDependencyClassification(
|
||||
@@ -204,7 +210,7 @@ async def test_sync_plugins_reloads_only_updated_running_plugins(monkeypatch) ->
|
||||
"""源码同步只重载对应运行实例,不重启其他插件。"""
|
||||
manager = MagicMock()
|
||||
manager.sync.return_value = ["UpdatedPlugin"]
|
||||
manager.install_plugin_missing_dependencies_with_status.return_value = (
|
||||
manager.async_install_plugin_missing_dependencies_with_status.return_value = (
|
||||
PluginDependencyInstallResult(missing=[], success=True)
|
||||
)
|
||||
manager.classify_plugins.return_value = PluginDependencyClassification(
|
||||
@@ -232,7 +238,7 @@ async def test_sync_plugins_reloads_running_plugin_after_dependency_recovery(
|
||||
"""依赖恢复后,已运行的旧实例必须切换到新源码。"""
|
||||
manager = MagicMock()
|
||||
manager.sync.return_value = []
|
||||
manager.install_plugin_missing_dependencies_with_status.return_value = (
|
||||
manager.async_install_plugin_missing_dependencies_with_status.return_value = (
|
||||
PluginDependencyInstallResult(missing=["demo>=1"], success=True)
|
||||
)
|
||||
manager.classify_plugins.return_value = PluginDependencyClassification(
|
||||
@@ -258,7 +264,7 @@ async def test_sync_plugins_keeps_runtime_when_nothing_changed(monkeypatch) -> N
|
||||
"""源码和依赖均无变化时保留首次初始化结果。"""
|
||||
manager = MagicMock()
|
||||
manager.sync.return_value = []
|
||||
manager.install_plugin_missing_dependencies_with_status.return_value = (
|
||||
manager.async_install_plugin_missing_dependencies_with_status.return_value = (
|
||||
PluginDependencyInstallResult(missing=[], success=True)
|
||||
)
|
||||
manager.classify_plugins.return_value = PluginDependencyClassification(
|
||||
@@ -283,7 +289,7 @@ async def test_sync_plugins_keeps_event_loop_responsive_during_activation(
|
||||
"""插件初始化运行在线程池时,Web 事件循环仍可继续调度。"""
|
||||
manager = MagicMock()
|
||||
manager.sync.return_value = []
|
||||
manager.install_plugin_missing_dependencies_with_status.return_value = (
|
||||
manager.async_install_plugin_missing_dependencies_with_status.return_value = (
|
||||
PluginDependencyInstallResult(missing=[], success=True)
|
||||
)
|
||||
manager.classify_plugins.return_value = PluginDependencyClassification(
|
||||
@@ -299,6 +305,9 @@ async def test_sync_plugins_keeps_event_loop_responsive_during_activation(
|
||||
time.sleep(0.1)
|
||||
|
||||
manager.start.side_effect = slow_start
|
||||
manager.async_install_plugin_missing_dependencies_with_status = AsyncMock(
|
||||
return_value=PluginDependencyInstallResult(missing=[], success=True),
|
||||
)
|
||||
monkeypatch.setattr(plugins_initializer, "configure_plugin_services", lambda: None)
|
||||
monkeypatch.setattr(plugins_initializer, "PluginManager", lambda: manager)
|
||||
monkeypatch.setattr(plugins_initializer, "register_plugin_api", MagicMock())
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.adapters.system.plugin.package import PluginPackageManager
|
||||
|
||||
|
||||
@@ -46,6 +49,23 @@ def test_checkpoint_rollback_removes_new_package(monkeypatch, tmp_path):
|
||||
assert not checkpoint.transaction_dir.exists()
|
||||
|
||||
|
||||
def test_rollback_does_not_delete_package_when_snapshot_is_missing(monkeypatch, tmp_path):
|
||||
"""补偿快照损坏时先失败,不能先删除当前可用插件。"""
|
||||
manager = _manager(monkeypatch, tmp_path)
|
||||
plugin_dir = tmp_path / "app" / "plugins" / "demoplugin"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "__init__.py").write_text("old", encoding="utf-8")
|
||||
|
||||
checkpoint = manager.checkpoint("DemoPlugin")
|
||||
shutil.rmtree(checkpoint.transaction_dir / "package")
|
||||
(plugin_dir / "__init__.py").write_text("new", encoding="utf-8")
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
manager.rollback(checkpoint)
|
||||
|
||||
assert (plugin_dir / "__init__.py").read_text(encoding="utf-8") == "new"
|
||||
|
||||
|
||||
def test_local_sync_failure_restores_previous_runtime_copy(monkeypatch, tmp_path):
|
||||
"""本地来源不可复制时不得丢失已经运行的插件副本。"""
|
||||
manager = _manager(monkeypatch, tmp_path)
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
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
|
||||
|
||||
import psutil
|
||||
import pytest
|
||||
|
||||
from app.runtime.state import SystemHelper
|
||||
@@ -155,6 +159,148 @@ 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}")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_subprocess_cancellation_reaps_process_tree(tmp_path):
|
||||
"""取消安装命令时,子进程派生的构建进程也不得继续运行。"""
|
||||
marker = tmp_path / "pids"
|
||||
child_code = "import time; time.sleep(60)"
|
||||
command = [
|
||||
sys.executable,
|
||||
"-c",
|
||||
(
|
||||
"from pathlib import Path; import subprocess, os, time; "
|
||||
f"child = subprocess.Popen([{sys.executable!r}, '-c', {child_code!r}]); "
|
||||
f"Path({str(marker)!r}).write_text(str(os.getpid()) + ':' + str(child.pid)); "
|
||||
"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()
|
||||
|
||||
pids = [int(value) for value in marker.read_text().split(":")]
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
for _ in range(100):
|
||||
alive = []
|
||||
for pid in pids:
|
||||
try:
|
||||
process = psutil.Process(pid)
|
||||
if not process.is_running() or process.status() == psutil.STATUS_ZOMBIE:
|
||||
continue
|
||||
except (psutil.Error, OSError):
|
||||
continue
|
||||
alive.append(pid)
|
||||
if not alive:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
else:
|
||||
pytest.fail(f"进程树仍在运行:{alive}")
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="Windows 没有 POSIX 进程组信号语义")
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_subprocess_reaps_descendant_after_early_pipe_close(tmp_path):
|
||||
"""父进程关闭管道后,忽略终止信号的后代也必须被强制回收。"""
|
||||
marker = tmp_path / "pids"
|
||||
child_code = (
|
||||
"import os, signal, time; os.close(1); os.close(2); "
|
||||
"signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(60)"
|
||||
)
|
||||
command = [
|
||||
sys.executable,
|
||||
"-c",
|
||||
(
|
||||
"from pathlib import Path; import os, signal, subprocess, time; "
|
||||
f"child = subprocess.Popen([{sys.executable!r}, '-c', {child_code!r}], "
|
||||
"start_new_session=True); "
|
||||
f"Path({str(marker)!r}).write_text(str(os.getpid()) + ':' + str(child.pid)); "
|
||||
"signal.signal(signal.SIGTERM, lambda *_: os._exit(0)); 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()
|
||||
|
||||
pids = [int(value) for value in marker.read_text().split(":")]
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
for _ in range(100):
|
||||
alive = []
|
||||
for pid in pids:
|
||||
try:
|
||||
process = psutil.Process(pid)
|
||||
if not process.is_running() or process.status() == psutil.STATUS_ZOMBIE:
|
||||
continue
|
||||
except (psutil.Error, OSError):
|
||||
continue
|
||||
alive.append(pid)
|
||||
if not alive:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
else:
|
||||
pytest.fail(f"通信已结束但进程树仍在运行:{alive}")
|
||||
|
||||
|
||||
def test_execute_with_subprocess_redacts_userinfo_from_stdout_and_stderr():
|
||||
error = subprocess.CalledProcessError(
|
||||
returncode=1,
|
||||
|
||||
Reference in New Issue
Block a user