Merge remote-tracking branch 'origin/v3' into v3

# Conflicts:
#	tests/fixtures/architecture/dependency-baseline.json
This commit is contained in:
jxxghp
2026-08-23 14:57:03 +08:00
26 changed files with 1633 additions and 146 deletions
+16 -8
View File
@@ -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",
+1 -1
View File
@@ -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,
)
+1 -1
View File
@@ -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,
)
+2 -2
View File
@@ -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,
)
+29 -1
View File
@@ -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
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,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):
"""
+211 -3
View File
@@ -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():
"""统计上报失败属于非关键副作用,不得撤销已成功的本地安装。"""
+16 -7
View File
@@ -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())
+20
View File
@@ -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)
+146
View File
@@ -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,