feat: 完成插件原生依赖重启激活闭环 (#6491)

* feat(plugins): track native dependency activation

* fix(plugins): finalize native restart state lifecycle
This commit is contained in:
InfinityPacer
2026-08-29 02:10:50 +08:00
committed by GitHub
parent 7b8f8c16a4
commit 92066dfad4
17 changed files with 1285 additions and 99 deletions
+130 -40
View File
@@ -1,6 +1,4 @@
import asyncio
from collections import deque
from dataclasses import dataclass
import importlib
import io
import json
@@ -15,8 +13,11 @@ import time
import traceback
import uuid
import zipfile
from collections import deque
from dataclasses import dataclass
from importlib.metadata import distributions
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import Any, Dict, List, Optional, Tuple, Set, Callable, Awaitable, Iterator, Sequence
from typing import Any, Awaitable, Callable, Dict, Iterator, List, Optional, Sequence, Set, Tuple
from urllib.parse import parse_qs, quote, unquote, urlparse, urlsplit
import aiofiles
@@ -25,20 +26,13 @@ import httpx2
from anyio import Path as AsyncPath
from packaging.markers import default_environment
from packaging.requirements import InvalidRequirement, Requirement
from packaging.specifiers import SpecifierSet, InvalidSpecifier
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.utils import canonicalize_name
from packaging.version import Version, InvalidVersion
from importlib.metadata import distributions
from packaging.version import InvalidVersion, Version
from requests import Response
from app.runtime.cache import cached, is_fresh
from app.foundation.environment import is_free_threaded_runtime
from app.runtime.dependencies import (
iter_runtime_profile_requirement_strings,
iter_runtime_requirement_strings,
runtime_excluded_dependency_pairs,
)
from app.runtime.settings import get_runtime_setting
from app.adapters.network.http import AsyncRequestUtils, RequestUtils
from app.adapters.system.host import SystemUtils
from app.adapters.system.package import (
PackageInstallRequest,
build_package_install_strategies,
@@ -47,22 +41,30 @@ from app.adapters.system.package import (
)
from app.adapters.system.plugin.manifest import (
PluginDependencyManifestError,
dependency_manifest_declares_installation,
load_dependency_file,
load_dependency_manifest,
)
from app.runtime.log import logger
from app.runtime.observability import observe_compat_facade
from app.foundation.environment import is_free_threaded_runtime
from app.foundation.singleton import WeakSingleton
from app.foundation.url import UrlUtils
from app.foundation.version import compare_version
from app.runtime.cache import cached, is_fresh
from app.runtime.dependencies import (
iter_runtime_profile_requirement_strings,
iter_runtime_requirement_strings,
runtime_excluded_dependency_pairs,
)
from app.runtime.execution import (
await_task_to_terminal,
)
from app.runtime.execution import (
run_in_threadpool_to_completion as _await_thread_operation,
)
from app.runtime.log import logger
from app.runtime.observability import observe_compat_facade
from app.runtime.settings import get_runtime_setting
from app.runtime.tasks import get_task_registry
from app.adapters.network.http import RequestUtils, AsyncRequestUtils
from app.foundation.singleton import WeakSingleton
from app.foundation.version import compare_version
from app.adapters.system.host import SystemUtils
from app.foundation.url import UrlUtils
from app.runtime.version import get_app_version
# 插件市场只通过 runtime 读取端口消费组合根的最新配置。
@@ -1059,7 +1061,8 @@ class PluginHelper(metaclass=WeakSingleton):
)
def __install_package(self, pid: str, repo_url: str, package_version: Optional[str] = None,
release_version: Optional[str] = None, force_install: bool = False) \
release_version: Optional[str] = None, force_install: bool = False,
before_dependency_install: Optional[Callable[[], None]] = None) \
-> Tuple[bool, str]:
"""执行已通过来源准入的同步包安装,不负责身份或运行态提交。"""
if self.is_local_repo_url(repo_url):
@@ -1067,6 +1070,7 @@ class PluginHelper(metaclass=WeakSingleton):
pid=pid,
repo_url=repo_url,
force_install=force_install,
before_dependency_install=before_dependency_install,
)
if SystemUtils.is_frozen():
@@ -1124,7 +1128,13 @@ class PluginHelper(metaclass=WeakSingleton):
release_tag,
)
return self.__install_flow_sync(pid, force_install, prepare_selected_release, repo_url)
return self.__install_flow_sync(
pid,
force_install,
prepare_selected_release,
repo_url,
before_dependency_install,
)
if release_tag:
# 当前索引 Release 失败时回退文件列表,避免发布产物短暂滞后阻断安装。
@@ -1140,12 +1150,24 @@ class PluginHelper(metaclass=WeakSingleton):
self.__remove_old_plugin(pid)
return self.__prepare_content_via_filelist_sync(pid, user_repo, package_version)
return self.__install_flow_sync(pid, force_install, prepare_release, repo_url)
return self.__install_flow_sync(
pid,
force_install,
prepare_release,
repo_url,
before_dependency_install,
)
# 未声明 release 打包的插件继续使用文件列表方式安装。
def prepare_filelist() -> Tuple[bool, str]:
return self.__prepare_content_via_filelist_sync(pid, user_repo, package_version)
return self.__install_flow_sync(pid, force_install, prepare_filelist, repo_url)
return self.__install_flow_sync(
pid,
force_install,
prepare_filelist,
repo_url,
before_dependency_install,
)
def install_local(self, pid: str, repo_url: str = "", force_install: bool = False) -> Tuple[bool, str]:
"""通过宿主统一 Gateway 安装本地插件。"""
@@ -1163,6 +1185,7 @@ class PluginHelper(metaclass=WeakSingleton):
pid: str,
repo_url: str = "",
force_install: bool = False,
before_dependency_install: Optional[Callable[[], None]] = None,
) -> Tuple[bool, str]:
"""
执行已通过来源准入的本地插件包安装。
@@ -1214,7 +1237,8 @@ class PluginHelper(metaclass=WeakSingleton):
pid,
candidate.get("repo_path"),
candidate.get("package_version")
)
),
before_dependency_install=before_dependency_install,
)
def __get_file_list(self, pid: str, user_repo: str, package_version: Optional[str] = None) -> \
@@ -1302,7 +1326,11 @@ class PluginHelper(metaclass=WeakSingleton):
return True, ""
def __install_dependencies_if_required(self, pid: str) -> Tuple[bool, bool, str]:
def __install_dependencies_if_required(
self,
pid: str,
before_dependency_install: Optional[Callable[[], None]] = None,
) -> Tuple[bool, bool, str]:
"""
安装插件依赖。
:param pid: 插件 ID
@@ -1315,6 +1343,14 @@ class PluginHelper(metaclass=WeakSingleton):
logger.error(f"{pid} 依赖清单无效:{error}")
return True, False, str(error)
if manifest is not None:
if (
before_dependency_install is not None
and dependency_manifest_declares_installation(manifest)
):
try:
before_dependency_install()
except Exception as error: # noqa: BLE001 - 观察失败不能阻断安装
logger.warning(f"{pid} 依赖安装前状态记录失败:{error}")
logger.info(f"{pid} 存在依赖,开始尝试安装依赖")
success, error_message = self.install_packages_with_fallback(manifest.path)
return True, success, "" if success else error_message
@@ -2244,9 +2280,14 @@ class PluginHelper(metaclass=WeakSingleton):
compatible, message = self.check_plugin_system_version(meta)
return None if compatible else message
def __install_flow_sync(self, pid: str, force_install: bool,
prepare_content: Callable[[], Tuple[bool, str]],
repo_url: Optional[str] = None) -> Tuple[bool, str]:
def __install_flow_sync(
self,
pid: str,
force_install: bool,
prepare_content: Callable[[], Tuple[bool, str]],
repo_url: Optional[str] = None,
before_dependency_install: Optional[Callable[[], None]] = None,
) -> Tuple[bool, str]:
"""
同步安装统一流程:备份→清理→准备内容→安装依赖→上报
prepare_content 负责把插件文件放到 app/plugins/{pid}
@@ -2268,7 +2309,14 @@ class PluginHelper(metaclass=WeakSingleton):
logger.warn(f"{pid} 已清理对应插件目录,请尝试重新安装")
return False, message
dependencies_exist, dep_ok, dep_msg = self.__install_dependencies_if_required(pid)
dependencies_exist, dep_ok, dep_msg = (
self.__install_dependencies_if_required(
pid,
before_dependency_install,
)
if before_dependency_install is not None
else self.__install_dependencies_if_required(pid)
)
if dependencies_exist and not dep_ok:
logger.error(f"{pid} 依赖安装失败:{dep_msg}")
if backup_dir:
@@ -3145,7 +3193,11 @@ class PluginHelper(metaclass=WeakSingleton):
async with aiofiles.open(dst_item, 'wb') as dst_file:
await dst_file.write(content)
async def __async_install_dependencies_if_required(self, pid: str) -> Tuple[bool, bool, str]:
async def __async_install_dependencies_if_required(
self,
pid: str,
before_dependency_install: Optional[Callable[[], None]] = None,
) -> Tuple[bool, bool, str]:
"""
异步安装插件依赖。
:param pid: 插件 ID
@@ -3158,6 +3210,14 @@ class PluginHelper(metaclass=WeakSingleton):
logger.error(f"{pid} 依赖清单无效:{error}")
return True, False, str(error)
if manifest is not None:
if (
before_dependency_install is not None
and dependency_manifest_declares_installation(manifest)
):
try:
await _await_thread_operation(before_dependency_install)
except Exception as error: # noqa: BLE001 - 观察失败不能阻断安装
logger.warning(f"{pid} 依赖安装前状态记录失败:{error}")
logger.info(f"{pid} 存在依赖,开始尝试安装依赖")
success, error_message = await self.__async_install_packages_with_fallback(manifest.path)
return True, success, "" if success else error_message
@@ -3213,6 +3273,7 @@ class PluginHelper(metaclass=WeakSingleton):
package_version: Optional[str] = None,
release_version: Optional[str] = None,
force_install: bool = False,
before_dependency_install: Optional[Callable[[], None]] = None,
) -> Tuple[bool, str]:
"""执行已通过来源准入的异步包安装,不负责身份或运行态提交。"""
if self.is_local_repo_url(repo_url):
@@ -3221,6 +3282,7 @@ class PluginHelper(metaclass=WeakSingleton):
pid,
repo_url,
force_install,
before_dependency_install,
)
if SystemUtils.is_frozen():
@@ -3278,7 +3340,13 @@ class PluginHelper(metaclass=WeakSingleton):
release_tag,
)
return await self.__install_flow_async(pid, force_install, prepare_selected_release, repo_url)
return await self.__install_flow_async(
pid,
force_install,
prepare_selected_release,
repo_url,
before_dependency_install,
)
if release_tag:
# 当前索引 Release 失败时回退文件列表,保持同步与异步安装一致。
@@ -3294,12 +3362,24 @@ class PluginHelper(metaclass=WeakSingleton):
await self.__async_remove_old_plugin(pid)
return await self.__prepare_content_via_filelist_async(pid, user_repo, package_version)
return await self.__install_flow_async(pid, force_install, prepare_release, repo_url)
return await self.__install_flow_async(
pid,
force_install,
prepare_release,
repo_url,
before_dependency_install,
)
# 未声明 release 打包的插件继续使用文件列表方式安装。
async def prepare_filelist() -> Tuple[bool, str]:
return await self.__prepare_content_via_filelist_async(pid, user_repo, package_version)
return await self.__install_flow_async(pid, force_install, prepare_filelist, repo_url)
return await self.__install_flow_async(
pid,
force_install,
prepare_filelist,
repo_url,
before_dependency_install,
)
async def __async_get_plugin_meta(self, pid: str, repo_url: str,
package_version: Optional[str]) -> dict:
@@ -3315,9 +3395,14 @@ class PluginHelper(metaclass=WeakSingleton):
logger.warn(f"获取插件 {pid} 元数据失败:{e}")
return {}
async def __install_flow_async(self, pid: str, force_install: bool,
prepare_content: Callable[[], Awaitable[Tuple[bool, str]]],
repo_url: Optional[str] = None) -> Tuple[bool, str]:
async def __install_flow_async(
self,
pid: str,
force_install: bool,
prepare_content: Callable[[], Awaitable[Tuple[bool, str]]],
repo_url: Optional[str] = None,
before_dependency_install: Optional[Callable[[], None]] = None,
) -> Tuple[bool, str]:
"""
异步安装流程,处理插件内容准备、依赖安装和注册
"""
@@ -3340,7 +3425,12 @@ class PluginHelper(metaclass=WeakSingleton):
return False, message
dependencies_exist, dep_ok, dep_msg = (
await self.__async_install_dependencies_if_required(pid)
await self.__async_install_dependencies_if_required(
pid,
before_dependency_install,
)
if before_dependency_install is not None
else await self.__async_install_dependencies_if_required(pid)
)
if dependencies_exist and not dep_ok:
logger.error(f"{pid} 依赖安装失败:{dep_msg}")
+22 -1
View File
@@ -11,7 +11,6 @@ from packaging.requirements import Requirement
from app.runtime.log import logger
PYPROJECT_FILENAME = "pyproject.toml"
REQUIREMENTS_FILENAME = "requirements.txt"
DEPENDENCY_MANIFEST_PRIORITY = (
@@ -82,6 +81,28 @@ def load_dependency_file(path: Path) -> PluginDependencyManifest:
)
def dependency_manifest_declares_installation(
manifest: PluginDependencyManifest,
) -> bool:
"""判断清单是否声明了可能改变共享 Python 环境的安装内容。"""
if manifest.dependencies:
return True
if manifest.path.name == PYPROJECT_FILENAME:
return False
try:
lines = manifest.path.read_text(
encoding="utf-8",
errors="replace",
).splitlines()
except OSError:
# 安装器随后会报告文件错误;观察边界按可能发生写入处理。
return True
return any(
line.strip() and not line.lstrip().startswith("#")
for line in lines
)
def _load_pyproject_dependencies(path: Path) -> tuple[Requirement, ...]:
"""严格读取 PEP 621 ``project.dependencies``。"""
try:
+61 -1
View File
@@ -16,9 +16,16 @@ from app.runtime.execution import (
run_in_threadpool_to_completion as _await_thread_operation,
)
from app.runtime.log import logger
from app.runtime.native_dependencies import (
LoadedNativeDependencySnapshot,
NativeDependencyChange,
capture_loaded_native_dependencies,
detect_changed_native_dependencies,
)
from app.runtime.settings import get_runtime_setting
@dataclass(frozen=True, slots=True)
@dataclass(slots=True)
class PluginPackageCheckpoint:
"""记录运行目录快照及待提升的容器恢复备份。"""
@@ -30,6 +37,7 @@ class PluginPackageCheckpoint:
transaction_dir: Path
plugin_existed: bool
persistent_backup_existed: bool
native_dependencies: LoadedNativeDependencySnapshot | None = None
@property
def existed(self) -> bool:
@@ -187,6 +195,34 @@ class PluginPackageManager:
"""在线程池中清理已提交的插件包快照。"""
await _await_thread_operation(self.commit, checkpoint)
@staticmethod
def native_dependency_changes(
checkpoint: PluginPackageCheckpoint,
) -> tuple[NativeDependencyChange, ...]:
"""返回安装中被替换、但当前进程仍持有旧代码的原生发行包。"""
if checkpoint.native_dependencies is None:
return ()
try:
return detect_changed_native_dependencies(
checkpoint.native_dependencies
)
except Exception as error: # noqa: BLE001 - 诊断失败不能改写安装结果
logger.warning("检测插件原生依赖变更失败:%s", error)
return ()
async def async_native_dependency_changes(
self,
checkpoint: PluginPackageCheckpoint,
) -> tuple[NativeDependencyChange, ...]:
"""在线程池中比较原生发行包,避免文件枚举阻塞事件循环。"""
return cast(
tuple[NativeDependencyChange, ...],
await _await_thread_operation(
self.native_dependency_changes,
checkpoint,
),
)
@staticmethod
def rollback(checkpoint: PluginPackageCheckpoint) -> None:
"""兼容旧调用方,恢复运行目录和持久备份后清理恢复材料。"""
@@ -464,6 +500,7 @@ class PluginPackageManager:
package_version: Optional[str] = None,
release_version: Optional[str] = None,
force_install: bool = False,
checkpoint: PluginPackageCheckpoint | None = None,
) -> tuple[bool, str]:
"""同步安装插件包,下载过程继续复用既有市场兼容策略。"""
return cast(
@@ -474,6 +511,11 @@ class PluginPackageManager:
package_version=package_version,
release_version=release_version,
force_install=force_install,
before_dependency_install=(
(lambda: self.__capture_native_dependencies(checkpoint))
if checkpoint is not None
else None
),
),
)
@@ -484,6 +526,7 @@ class PluginPackageManager:
package_version: Optional[str] = None,
release_version: Optional[str] = None,
force_install: bool = False,
checkpoint: PluginPackageCheckpoint | None = None,
) -> tuple[bool, str]:
"""异步安装插件包,下载过程继续复用既有市场兼容策略。"""
return cast(
@@ -494,9 +537,26 @@ class PluginPackageManager:
package_version=package_version,
release_version=release_version,
force_install=force_install,
before_dependency_install=(
(lambda: self.__capture_native_dependencies(checkpoint))
if checkpoint is not None
else None
),
),
)
@staticmethod
def __capture_native_dependencies(
checkpoint: PluginPackageCheckpoint,
) -> None:
"""仅在插件依赖即将安装时记录当前进程的原生载荷。"""
if checkpoint.native_dependencies is not None:
return
try:
checkpoint.native_dependencies = capture_loaded_native_dependencies()
except Exception as error: # noqa: BLE001 - 诊断失败不能阻断插件安装
logger.warning("记录插件原生依赖安装前状态失败:%s", error)
def sync_local(self, plugin_id: str, source_dir: Path) -> bool:
"""用本地仓库内容原子替换运行副本,失败时恢复原目录。"""
source_dir = source_dir.resolve()
+46 -6
View File
@@ -58,6 +58,7 @@ from app.schemas.plugin import PluginCloneRequest as _SchemaPluginCloneRequest
from app.schemas.plugin import PluginDashboard as _SchemaPluginDashboard
from app.schemas.plugin import PluginDashboardMetaItem as _SchemaPluginDashboardMetaItem
from app.schemas.plugin import PluginFoldersData as _SchemaPluginFoldersData
from app.schemas.plugin import PluginInstallOutcome as _SchemaPluginInstallOutcome
from app.schemas.plugin import PluginRating as _SchemaPluginRating
from app.schemas.plugin import PluginRatingMap as _SchemaPluginRatingMap
from app.schemas.plugin import PluginRatingRequest as _SchemaPluginRatingRequest
@@ -446,6 +447,22 @@ async def runtime_status(
"""返回插件页轮询所需的轻量状态摘要。"""
plugin_manager = get_plugin_manager()
statuses = plugin_manager.get_plugin_runtime_statuses()
installed_plugin_ids = {
plugin_id.lower()
for plugin_id in (
get_configured_system_config().get(
SystemConfigKey.UserInstalledPlugins
)
or []
)
}
restart_requirements = {
plugin_id: distributions
for plugin_id, distributions in (
plugin_manager.get_plugin_restart_requirements().items()
)
if plugin_id.lower() in installed_plugin_ids
}
pending = {
_SchemaPluginRuntimeStatus.SOURCE_MISSING,
_SchemaPluginRuntimeStatus.DEPENDENCY_PENDING,
@@ -460,6 +477,7 @@ async def runtime_status(
generation=plugin_manager.get_plugin_runtime_generation(),
pending_count=sum(status in pending for status in statuses.values()),
failed_count=sum(status in failed for status in statuses.values()),
restart_required_plugin_ids=sorted(restart_requirements),
)
@@ -656,7 +674,11 @@ def reload_plugin(
)
@router.get("/install/{plugin_id}", summary="安装插件", response_model=_SchemaResponse[None])
@router.get(
"/install/{plugin_id}",
summary="安装插件",
response_model=_SchemaResponse[_SchemaPluginInstallOutcome],
)
async def install(
plugin_id: str,
repo_url: Optional[str] = "",
@@ -676,7 +698,13 @@ async def install(
)
if not result.success:
return _SchemaResponse(success=False, message=result.message)
return _SchemaResponse(success=True)
return _SchemaResponse(
success=True,
message=result.message,
data=_SchemaPluginInstallOutcome(
restart_required=result.restart_required,
),
)
@router.get(
@@ -743,7 +771,7 @@ async def get_plugin_source_options(
@router.post(
"/source/{plugin_id}/install",
summary="按明确来源安装插件",
response_model=_SchemaResponse[None],
response_model=_SchemaResponse[_SchemaPluginInstallOutcome],
)
async def install_plugin_from_source(
plugin_id: str,
@@ -760,13 +788,19 @@ async def install_plugin_from_source(
)
if not result.success:
return _SchemaResponse(success=False, message=result.message)
return _SchemaResponse(success=True)
return _SchemaResponse(
success=True,
message=result.message,
data=_SchemaPluginInstallOutcome(
restart_required=result.restart_required,
),
)
@router.post(
"/source/{plugin_id}",
summary="切换插件来源",
response_model=_SchemaResponse[None],
response_model=_SchemaResponse[_SchemaPluginInstallOutcome],
)
async def change_plugin_source(
plugin_id: str,
@@ -785,7 +819,13 @@ async def change_plugin_source(
)
if not result.success:
return _SchemaResponse(success=False, message=result.message)
return _SchemaResponse(success=True)
return _SchemaResponse(
success=True,
message=result.message,
data=_SchemaPluginInstallOutcome(
restart_required=result.restart_required,
),
)
@router.get(
+90 -14
View File
@@ -19,6 +19,7 @@ from app.application.plugin.transaction import (
)
from app.runtime.execution import await_task_to_terminal
from app.runtime.log import logger
from app.runtime.native_dependencies import NativeDependencyChange
from app.schemas.exception import (
PersistenceUnavailableError,
PluginMutationRejectedError,
@@ -32,6 +33,7 @@ PluginReloader = Callable[[str], Awaitable[PluginRuntimeStatus]]
PluginRegistrationRefresher = Callable[[str], Awaitable[object]]
PluginMutationAdmission = Callable[[str], ContextManager[None]]
PluginPackageWriteGuard = Callable[[str], ContextManager[None]]
RestartRequiredRecorder = Callable[[str, tuple[str, ...]], None]
T = TypeVar("T")
@@ -59,6 +61,7 @@ class PluginPackageTransactionPort(Protocol):
package_version: str | None = None,
release_version: str | None = None,
force_install: bool = False,
checkpoint: PluginPackageCheckpoint | None = None,
) -> tuple[bool, str]:
"""执行已经通过来源准入的原始包安装。"""
@@ -92,6 +95,12 @@ class PluginPackageTransactionPort(Protocol):
async def async_payload_receipt(self, plugin_id: str) -> str:
"""读取当前运行目录的稳定载荷收据。"""
async def async_native_dependency_changes(
self,
checkpoint: PluginPackageCheckpoint,
) -> tuple[NativeDependencyChange, ...]:
"""返回安装期间被替换的已加载原生发行包。"""
@dataclass(frozen=True, slots=True)
class PluginInstallRollback:
@@ -118,6 +127,7 @@ class PluginInstallResult:
installed_list_persisted: bool = False
runtime_reloaded: bool = False
registrations_refreshed: bool = False
restart_required: bool = False
reported: bool = False
report_error: str = ""
failure_stage: str | None = None
@@ -138,6 +148,8 @@ class _InstallState:
package_installed: bool = False
runtime_touched: bool = False
registrations_touched: bool = False
native_dependencies_checked: bool = False
native_dependency_changes: tuple[NativeDependencyChange, ...] = ()
committed: bool = False
commit_unknown: bool = False
@@ -158,6 +170,7 @@ class PluginInstallCommand:
registration_refresher: PluginRegistrationRefresher,
mutation: PluginMutationAdmission,
package_write_guard: PluginPackageWriteGuard,
restart_required_recorder: RestartRequiredRecorder,
clock: Callable[[], datetime],
transaction_id_factory: Callable[[], str],
) -> None:
@@ -172,6 +185,7 @@ class PluginInstallCommand:
self.__registration_refresher = registration_refresher
self.__mutation = mutation
self.__package_write_guard = package_write_guard
self.__restart_required_recorder = restart_required_recorder
self.__clock = clock
self.__transaction_id_factory = transaction_id_factory
@@ -327,10 +341,15 @@ class PluginInstallCommand:
package_version=candidate.package_generation,
release_version=release_version,
force_install=True,
checkpoint=state.checkpoint,
)
)
state.package_installed = package_installed
except asyncio.CancelledError:
await self.__record_native_dependency_changes(plugin_id, state)
raise
except Exception as error:
await self.__record_native_dependency_changes(plugin_id, state)
if isinstance(error, PersistenceUnavailableError):
await self.__fail_prepared(plugin_id=plugin_id, state=state)
raise
@@ -340,6 +359,7 @@ class PluginInstallCommand:
stage="package_install",
message=str(error),
)
await self.__record_native_dependency_changes(plugin_id, state)
if not package_installed:
return await self.__failure_result(
plugin_id=plugin_id,
@@ -348,6 +368,8 @@ class PluginInstallCommand:
message=message,
)
runtime_reloaded = False
registrations_refreshed = False
try:
state.stage = "payload_receipt"
receipt = await self.__await_side_effect(
@@ -381,12 +403,17 @@ class PluginInstallCommand:
state.stage = "runtime_reload"
state.runtime_touched = True
await self.__reload_active(plugin_id)
state.stage = "registration_refresh"
state.registrations_touched = True
await self.__await_side_effect(
self.__registration_refresher(plugin_id)
runtime_reloaded = await self.__reload_active(
plugin_id,
allow_pending_restart=bool(state.native_dependency_changes),
)
if runtime_reloaded:
state.stage = "registration_refresh"
state.registrations_touched = True
await self.__await_side_effect(
self.__registration_refresher(plugin_id)
)
registrations_refreshed = True
except asyncio.CancelledError:
raise
except Exception as error:
@@ -424,8 +451,9 @@ class PluginInstallCommand:
"重启后将自动恢复"
),
package_installed=True,
runtime_reloaded=True,
registrations_refreshed=True,
runtime_reloaded=runtime_reloaded,
registrations_refreshed=registrations_refreshed,
restart_required=bool(state.native_dependency_changes),
failure_stage="database_commit_unknown",
)
if not outcome:
@@ -453,7 +481,10 @@ class PluginInstallCommand:
and not isinstance(candidate, PluginLocalCandidate)
),
)
result_message = message or "插件安装成功"
if state.native_dependency_changes:
result_message = "插件已安装,重启 MoviePilot 后完成依赖更新"
else:
result_message = message or "插件安装成功"
if checkpoint_cleanup_error:
result_message = f"{result_message};安装事务待下次启动继续清理"
if report_error:
@@ -463,8 +494,9 @@ class PluginInstallCommand:
message=result_message,
package_installed=True,
installed_list_persisted=True,
runtime_reloaded=True,
registrations_refreshed=True,
runtime_reloaded=runtime_reloaded,
registrations_refreshed=registrations_refreshed,
restart_required=bool(state.native_dependency_changes),
reported=reported,
report_error=report_error,
checkpoint_cleanup_error=checkpoint_cleanup_error,
@@ -543,13 +575,57 @@ class PluginInstallCommand:
report_error=report_error,
)
async def __reload_active(self, plugin_id: str) -> None:
"""重载只有进入 ACTIVE 才能作为安装或刷新成功继续提交。"""
async def __reload_active(
self,
plugin_id: str,
*,
allow_pending_restart: bool = False,
) -> bool:
"""重载插件;原生载荷已替换时允许等待新进程完成激活。"""
runtime_status = await self.__await_side_effect(
self.__target_reloader(plugin_id)
)
if runtime_status is not PluginRuntimeStatus.ACTIVE:
raise RuntimeError("插件加载失败,请查看插件日志")
if runtime_status is PluginRuntimeStatus.ACTIVE:
return True
if allow_pending_restart:
logger.warning(
"插件 %s 的原生依赖已更新,当前进程重载未激活新载荷,"
"将在重启后重新加载",
plugin_id,
)
return False
raise RuntimeError("插件加载失败,请查看插件日志")
async def __record_native_dependency_changes(
self,
plugin_id: str,
state: _InstallState,
) -> None:
"""记录共享依赖越过可回滚边界后的进程级激活要求。"""
if state.native_dependencies_checked or state.checkpoint is None:
return
state.native_dependencies_checked = True
try:
changes = await self.__await_side_effect(
self.__packages.async_native_dependency_changes(state.checkpoint)
)
except Exception as error: # noqa: BLE001 - 诊断失败不能改写安装终态
logger.warning(
"检测插件 %s 原生依赖变更失败,继续使用原安装结果:%s",
plugin_id,
error,
)
return
state.native_dependency_changes = changes
if not changes:
return
packages = tuple(change.distribution for change in changes)
self.__restart_required_recorder(plugin_id, packages)
logger.warning(
"插件 %s 更新了当前进程已加载的原生依赖,重启后完整生效:%s",
plugin_id,
", ".join(packages),
)
async def __finish_committed(self, state: _InstallState) -> str:
"""幂等清理 COMMITTED 事务;失败时保留 journal 供启动回放。"""
+30 -3
View File
@@ -13,6 +13,7 @@ class PluginRegistry:
self._classes: Dict[str, Any] = {}
self._running: Dict[str, Any] = {}
self._runtime_statuses: Dict[str, PluginRuntimeStatus] = {}
self._restart_required_plugins: Dict[str, tuple[str, ...]] = {}
self._settling = False
self._generation = 0
@@ -69,6 +70,24 @@ class PluginRegistry:
"""复制插件状态表,避免后台加载期间迭代失效。"""
return dict(self._runtime_statuses)
def mark_restart_required(
self,
plugin_id: str,
distributions: tuple[str, ...],
) -> None:
"""记录当前进程仍持有旧原生载荷的插件和发行包。"""
normalized = tuple(sorted(set(distributions)))
previous = self._restart_required_plugins.get(plugin_id, ())
merged = tuple(sorted(set(previous).union(normalized)))
if previous == merged:
return
self._restart_required_plugins[plugin_id] = merged
self._generation += 1
def restart_required_snapshot(self) -> Dict[str, tuple[str, ...]]:
"""返回重启后才能完整激活的插件及原生发行包。"""
return dict(self._restart_required_plugins)
def set_settling(self, settling: bool) -> None:
"""标记启动后的插件源码与依赖收敛任务是否仍在执行。"""
if self._settling == settling:
@@ -90,13 +109,21 @@ class PluginRegistry:
"""同时移除指定插件类、运行实例和状态。"""
self._classes.pop(plugin_id, None)
self._running.pop(plugin_id, None)
if self._runtime_statuses.pop(plugin_id, None) is not None:
status_removed = self._runtime_statuses.pop(plugin_id, None) is not None
restart_requirement_removed = (
self._restart_required_plugins.pop(plugin_id, None) is not None
)
if status_removed or restart_requirement_removed:
self._generation += 1
def clear(self) -> None:
"""原地清空注册表,保持外部持有的兼容字典引用有效。"""
self._classes.clear()
self._running.clear()
if self._runtime_statuses:
self._runtime_statuses.clear()
had_runtime_state = bool(
self._runtime_statuses or self._restart_required_plugins
)
self._runtime_statuses.clear()
self._restart_required_plugins.clear()
if had_runtime_state:
self._generation += 1
+39 -29
View File
@@ -20,49 +20,47 @@ from typing import (
from watchfiles import watch
from app.schemas.plugin import Plugin as _SchemaPlugin
from app.schemas.plugin import PluginDashboard as _SchemaPluginDashboard
from app.schemas.plugin import PluginInstance, PluginRuntimeStatus
from app.foundation.crypto import RSAUtils
from app.foundation.environment import is_free_threaded_runtime, is_gil_enabled
from app.foundation.singleton import Singleton
from app.foundation.version import compare_version
from app.runtime.execution import run_in_threadpool_to_completion
from app.runtime.log import logger
from app.runtime.observability import observe_compat_facade
from app.runtime.settings import get_runtime_setting
from app.runtime.thread import ThreadHelper
from app.runtime.events import EventHandlerBinding, eventmanager
from app.runtime.reload import ConfigReloadMixin
from app.runtime.extensions.plugin.loader import PluginLoader
from app.runtime.extensions.plugin.lifecycle import PluginLifecycle
from app.runtime.extensions.plugin.metadata import PluginMetadataMapper
from app.runtime.extensions.plugin.monitor import (
PluginChangeMonitor,
PluginMonitorController,
)
from app.runtime.extensions.plugin.projection import PluginProjection
from app.runtime.extensions.plugin.registry import PluginRegistry
from app.runtime.extensions.plugin.storage import get_plugin_storage
from app.runtime.extensions.plugin.system import get_plugin_system
from app.runtime.extensions.plugin.tools import PluginToolCatalog
from app.runtime.extensions.plugin.sync import (
LocalPluginSyncService,
PluginSyncService,
)
from app.runtime.extensions.plugin.clone import PluginCloneService
from app.runtime.execution import run_in_threadpool_to_completion
from app.runtime.extensions.plugin.access import PluginAccessPolicy
from app.runtime.extensions.plugin.admission import PluginMutationAdmission
from app.runtime.extensions.plugin.catalog import PluginCatalogFacade
from app.runtime.extensions.plugin.paths import PluginPathResolver
from app.runtime.extensions.plugin.clone import PluginCloneService
from app.runtime.extensions.plugin.dependency import (
PluginDependencyClassification,
PluginDependencyInstallResult,
PluginDependencyService,
)
from app.runtime.extensions.plugin.storage import PluginConfigStore, PluginInstanceStore
from app.runtime.extensions.plugin.lifecycle import PluginLifecycle
from app.runtime.extensions.plugin.loader import PluginLoader
from app.runtime.extensions.plugin.metadata import PluginMetadataMapper
from app.runtime.extensions.plugin.monitor import (
PluginChangeMonitor,
PluginMonitorController,
)
from app.runtime.extensions.plugin.paths import PluginPathResolver
from app.runtime.extensions.plugin.projection import PluginProjection
from app.runtime.extensions.plugin.registry import PluginRegistry
from app.runtime.extensions.plugin.storage import PluginConfigStore, PluginInstanceStore, get_plugin_storage
from app.runtime.extensions.plugin.sync import (
LocalPluginSyncService,
PluginSyncService,
)
from app.runtime.extensions.plugin.system import get_plugin_system
from app.runtime.extensions.plugin.tools import PluginToolCatalog
from app.runtime.log import logger
from app.runtime.observability import observe_compat_facade
from app.runtime.reload import ConfigReloadMixin
from app.runtime.settings import get_runtime_setting
from app.runtime.thread import ThreadHelper
from app.schemas.exception import PluginMutationRejectedError
from app.schemas.plugin import Plugin as _SchemaPlugin
from app.schemas.plugin import PluginDashboard as _SchemaPluginDashboard
from app.schemas.plugin import PluginInstance, PluginRuntimeStatus
from app.schemas.types import EventType, SystemConfigKey
LegacyDiagnosticsConfigurator = Callable[..., None]
@@ -951,6 +949,18 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
"""返回插件状态变化代次。"""
return self._plugin_registry.generation
def mark_plugin_restart_required(
self,
plugin_id: str,
distributions: tuple[str, ...],
) -> None:
"""记录已落盘但尚未由当前进程完整激活的原生依赖。"""
self._plugin_registry.mark_restart_required(plugin_id, distributions)
def get_plugin_restart_requirements(self) -> Dict[str, tuple[str, ...]]:
"""返回当前进程的插件原生依赖重启要求。"""
return self._plugin_registry.restart_required_snapshot()
def is_plugin_settling(self) -> bool:
"""返回插件源码和依赖是否仍在后台恢复。"""
return self._plugin_registry.settling
+262
View File
@@ -0,0 +1,262 @@
"""识别当前进程已经加载、但磁盘载荷在依赖安装中发生变化的原生发行包。"""
from __future__ import annotations
import ctypes
import os
import re
import sys
from dataclasses import dataclass
from importlib.metadata import Distribution, PackageNotFoundError, distribution, distributions
from pathlib import Path
from typing import Iterable
from packaging.utils import canonicalize_name
from app.runtime.log import logger
_NATIVE_FILE_PATTERN = re.compile(
r"(?:\.pyd|\.dll|\.dylib|\.so(?:\.\d+)*)$",
re.IGNORECASE,
)
@dataclass(frozen=True, slots=True)
class NativeArtifactState:
"""原生文件在一次依赖安装边界上的轻量磁盘指纹。"""
path: str
size: int
modified_ns: int
file_id: tuple[int, int]
@dataclass(frozen=True, slots=True)
class NativeDistributionState:
"""当前已加载发行包及其全部原生载荷的磁盘状态。"""
name: str
version: str
artifacts: tuple[NativeArtifactState, ...]
@dataclass(frozen=True, slots=True)
class LoadedNativeDependencySnapshot:
"""依赖安装前,当前进程已加载原生发行包的稳定快照。"""
distributions: tuple[NativeDistributionState, ...] = ()
@dataclass(frozen=True, slots=True)
class NativeDependencyChange:
"""一个已加载发行包在磁盘上被替换的原生载荷摘要。"""
distribution: str
previous_version: str
current_version: str | None
artifacts: tuple[str, ...]
def capture_loaded_native_dependencies() -> LoadedNativeDependencySnapshot:
"""捕获当前进程已加载原生文件所属发行包的磁盘状态。"""
loaded_paths = _loaded_native_paths()
if not loaded_paths:
return LoadedNativeDependencySnapshot()
states = []
for installed_distribution in _iter_installed_distributions():
state = _distribution_state(installed_distribution)
if state is None:
continue
if loaded_paths.intersection(_path_key(item.path) for item in state.artifacts):
states.append(state)
return LoadedNativeDependencySnapshot(
distributions=tuple(sorted(states, key=lambda item: item.name)),
)
def detect_changed_native_dependencies(
baseline: LoadedNativeDependencySnapshot,
) -> tuple[NativeDependencyChange, ...]:
"""比较安装后的磁盘状态,只报告基线中已经加载的原生发行包。"""
changes = []
for previous in baseline.distributions:
current = _current_distribution_state(previous.name)
previous_artifacts = {item.path: item for item in previous.artifacts}
current_artifacts = (
{item.path: item for item in current.artifacts}
if current is not None
else {}
)
changed_paths = tuple(
sorted(
Path(path).name
for path in previous_artifacts.keys() | current_artifacts.keys()
if previous_artifacts.get(path) != current_artifacts.get(path)
)
)
if not changed_paths:
continue
changes.append(
NativeDependencyChange(
distribution=previous.name,
previous_version=previous.version,
current_version=current.version if current is not None else None,
artifacts=changed_paths,
)
)
return tuple(changes)
def _iter_installed_distributions() -> Iterable[Distribution]:
"""隔离发行包枚举,便于在测试中构造确定性文件布局。"""
return distributions()
def _current_distribution_state(name: str) -> NativeDistributionState | None:
"""读取安装后的同名发行包;卸载完成时返回空状态。"""
try:
installed_distribution = distribution(name)
except PackageNotFoundError:
return None
return _distribution_state(installed_distribution)
def _distribution_state(
installed_distribution: Distribution,
) -> NativeDistributionState | None:
"""读取一个发行包的全部原生文件,避免漏掉扩展旁加载的本地库。"""
distribution_name = installed_distribution.metadata.get("Name")
if not distribution_name:
return None
artifacts = []
for relative_path in installed_distribution.files or ():
if not _is_native_file(str(relative_path)):
continue
path = Path(installed_distribution.locate_file(relative_path))
state = _artifact_state(path)
if state is not None:
artifacts.append(state)
if not artifacts:
return None
return NativeDistributionState(
name=canonicalize_name(distribution_name),
version=installed_distribution.version,
artifacts=tuple(sorted(artifacts, key=lambda item: item.path)),
)
def _artifact_state(path: Path) -> NativeArtifactState | None:
"""读取文件身份而不散列大型本地库,避免插件安装前产生明显 I/O。"""
try:
stat = path.stat()
except OSError:
return None
return NativeArtifactState(
path=_path_key(path),
size=stat.st_size,
modified_ns=stat.st_mtime_ns,
file_id=(stat.st_dev, stat.st_ino),
)
def _loaded_native_paths() -> set[str]:
"""合并 Python 扩展模块和平台加载器可见的原生文件路径。"""
paths = {
_path_key(module_file)
for module in tuple(sys.modules.values())
if (module_file := getattr(module, "__file__", None))
and _is_native_file(module_file)
}
for path in _platform_loaded_library_paths():
if _is_native_file(path):
paths.add(_path_key(path))
return paths
def _platform_loaded_library_paths() -> set[str]:
"""读取当前进程映射的本地库;失败时由 Python 扩展模块路径继续兜底。"""
try:
if sys.platform.startswith("linux"):
return _linux_loaded_library_paths()
if sys.platform == "darwin":
return _macos_loaded_library_paths()
if sys.platform == "win32":
return _windows_loaded_library_paths()
except (OSError, RuntimeError, ValueError) as error:
logger.debug("读取当前进程原生库映射失败:%s", error)
return set()
def _linux_loaded_library_paths() -> set[str]:
"""从 procfs 读取 Linux 当前进程的文件映射。"""
paths = set()
with Path("/proc/self/maps").open(encoding="utf-8", errors="replace") as maps:
for line in maps:
fields = line.rstrip().split(maxsplit=5)
if len(fields) != 6 or not fields[5].startswith("/"):
continue
paths.add(fields[5].removesuffix(" (deleted)"))
return paths
def _macos_loaded_library_paths() -> set[str]:
"""通过 dyld 查询 macOS 当前进程已装载镜像。"""
process = ctypes.CDLL(None)
image_count = process._dyld_image_count
image_count.argtypes = []
image_count.restype = ctypes.c_uint32
image_name = process._dyld_get_image_name
image_name.argtypes = [ctypes.c_uint32]
image_name.restype = ctypes.c_char_p
return {
os.fsdecode(path)
for index in range(image_count())
if (path := image_name(index))
}
def _windows_loaded_library_paths() -> set[str]:
"""通过 PSAPI 查询 Windows 当前进程已装载模块。"""
from ctypes import wintypes
process = ctypes.windll.kernel32.GetCurrentProcess()
module_count = 256
while True:
modules = (wintypes.HMODULE * module_count)()
needed = wintypes.DWORD()
if not ctypes.windll.psapi.EnumProcessModulesEx(
process,
modules,
ctypes.sizeof(modules),
ctypes.byref(needed),
0x03,
):
raise OSError(ctypes.get_last_error(), "EnumProcessModulesEx failed")
required_count = needed.value // ctypes.sizeof(wintypes.HMODULE)
if required_count <= module_count:
break
module_count = required_count
paths = set()
for module in modules[:required_count]:
buffer = ctypes.create_unicode_buffer(32768)
length = ctypes.windll.psapi.GetModuleFileNameExW(
process,
module,
buffer,
len(buffer),
)
if length:
paths.add(buffer.value)
return paths
def _is_native_file(path: str) -> bool:
"""识别 Python 扩展及其常见旁加载本地库。"""
return bool(_NATIVE_FILE_PATTERN.search(path))
def _path_key(path: str | os.PathLike[str]) -> str:
"""把不同平台的等价路径归一到可比较键。"""
return os.path.normcase(os.path.realpath(os.fspath(path)))
+1
View File
@@ -277,6 +277,7 @@ SCHEMA_EXPORTS = {
'PluginDataResetEventData': ('app.schemas.event', 'PluginDataResetEventData'),
'PluginFolderConfigData': ('app.schemas.plugin', 'PluginFolderConfigData'),
'PluginFoldersData': ('app.schemas.plugin', 'PluginFoldersData'),
'PluginInstallOutcome': ('app.schemas.plugin', 'PluginInstallOutcome'),
'PluginInstance': ('app.schemas.plugin', 'PluginInstance'),
'PluginMarketSyncData': ('app.schemas.system', 'PluginMarketSyncData'),
'PluginMarketSyncRequest': ('app.schemas.system', 'PluginMarketSyncRequest'),
+12
View File
@@ -145,6 +145,18 @@ class PluginRuntimeSummary(BaseModel):
generation: int = Field(description="插件运行状态变化代次")
pending_count: int = Field(description="仍处于准备阶段的插件数量")
failed_count: int = Field(description="加载失败或被策略阻止的插件数量")
restart_required_plugin_ids: List[str] = Field(
default_factory=list,
description="重启后才能完整激活新原生依赖的物理插件 ID",
)
class PluginInstallOutcome(BaseModel):
"""插件载荷写入成功后的前端反馈依据。"""
restart_required: bool = Field(
description="本次依赖更新是否需要重启 MoviePilot 才能完成"
)
class PluginCloneRequest(BaseModel):
+1
View File
@@ -165,6 +165,7 @@ def configure_plugin_services() -> None:
registration_refresher=refresh_plugin_registrations,
mutation=plugin_manager.mutation,
package_write_guard=plugin_manager.suppress_plugin_monitor,
restart_required_recorder=plugin_manager.mark_plugin_restart_required,
clock=lambda: datetime.now(timezone.utc),
transaction_id_factory=lambda: uuid.uuid4().hex,
)
+117
View File
@@ -0,0 +1,117 @@
"""已加载原生发行包的磁盘替换检测。"""
from pathlib import Path
from app.runtime import native_dependencies
class _Distribution:
"""提供 importlib.metadata.Distribution 的最小文件契约。"""
def __init__(self, root: Path, *, version: str) -> None:
self.root = root
self.version = version
self.metadata = {"Name": "Native_Demo"}
self.files = (
Path("native_demo/__init__.py"),
Path("native_demo/extension.cpython-314-darwin.so"),
Path("native_demo/.libs/libdemo.2.dylib"),
)
def locate_file(self, path: Path) -> Path:
"""把发行包相对路径定位到测试根目录。"""
return self.root / path
def _write_distribution(root: Path) -> tuple[Path, Path]:
"""创建一个扩展模块及其旁加载本地库。"""
extension = root / "native_demo/extension.cpython-314-darwin.so"
library = root / "native_demo/.libs/libdemo.2.dylib"
extension.parent.mkdir(parents=True)
library.parent.mkdir(parents=True)
(root / "native_demo/__init__.py").write_text("", encoding="utf-8")
extension.write_bytes(b"extension-v1")
library.write_bytes(b"library-v1")
return extension, library
def test_detects_changed_sibling_library_for_loaded_extension(tmp_path, monkeypatch):
"""扩展已加载时,同发行包旁加载库被替换也必须要求新进程激活。"""
extension, library = _write_distribution(tmp_path)
baseline_distribution = _Distribution(tmp_path, version="1.0.0")
monkeypatch.setattr(
native_dependencies,
"_iter_installed_distributions",
lambda: (baseline_distribution,),
)
monkeypatch.setattr(
native_dependencies,
"_loaded_native_paths",
lambda: {native_dependencies._path_key(extension)},
)
baseline = native_dependencies.capture_loaded_native_dependencies()
library.write_bytes(b"library-version-two")
current_distribution = _Distribution(tmp_path, version="2.0.0")
monkeypatch.setattr(
native_dependencies,
"distribution",
lambda _name: current_distribution,
)
changes = native_dependencies.detect_changed_native_dependencies(baseline)
assert len(changes) == 1
assert changes[0].distribution == "native-demo"
assert changes[0].previous_version == "1.0.0"
assert changes[0].current_version == "2.0.0"
assert changes[0].artifacts == ("libdemo.2.dylib",)
def test_ignores_native_distribution_that_is_not_loaded(tmp_path, monkeypatch):
"""尚未加载的原生包首次安装或更新不需要提示重启。"""
_write_distribution(tmp_path)
installed_distribution = _Distribution(tmp_path, version="1.0.0")
monkeypatch.setattr(
native_dependencies,
"_iter_installed_distributions",
lambda: (installed_distribution,),
)
monkeypatch.setattr(native_dependencies, "_loaded_native_paths", set)
baseline = native_dependencies.capture_loaded_native_dependencies()
assert baseline.distributions == ()
def test_unchanged_loaded_native_distribution_does_not_require_restart(
tmp_path,
monkeypatch,
):
"""依赖安装未改变原生文件时不得产生误报。"""
extension, _ = _write_distribution(tmp_path)
installed_distribution = _Distribution(tmp_path, version="1.0.0")
monkeypatch.setattr(
native_dependencies,
"_iter_installed_distributions",
lambda: (installed_distribution,),
)
monkeypatch.setattr(
native_dependencies,
"_loaded_native_paths",
lambda: {native_dependencies._path_key(extension)},
)
monkeypatch.setattr(
native_dependencies,
"distribution",
lambda _name: installed_distribution,
)
baseline = native_dependencies.capture_loaded_native_dependencies()
assert native_dependencies.detect_changed_native_dependencies(baseline) == ()
def test_recognizes_versioned_shared_object_names():
"""Linux 常见的 libname.so.N 旁加载库属于原生载荷。"""
assert native_dependencies._is_native_file("/venv/site-packages/demo/libdemo.so.3")
+14 -1
View File
@@ -297,14 +297,27 @@ def test_runtime_status_reports_pending_and_terminal_counts():
}
plugin_manager.is_plugin_settling.return_value = True
plugin_manager.get_plugin_runtime_generation.return_value = 7
plugin_manager.get_plugin_restart_requirements.return_value = {
"NativePlugin": ("native-demo",),
"RemovedPlugin": ("native-removed",),
}
config = MagicMock()
config.get.return_value = ["NativePlugin"]
with patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager):
with (
patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager),
patch(
"app.api.endpoints.plugin.get_configured_system_config",
return_value=config,
),
):
result = asyncio.run(runtime_status(None))
assert result.ready is False
assert result.generation == 7
assert result.pending_count == 2
assert result.failed_count == 1
assert result.restart_required_plugin_ids == ["NativePlugin"]
def test_reload_endpoint_reports_load_failure(monkeypatch):
+240 -3
View File
@@ -46,6 +46,7 @@ def test_package_manager_sync_preserves_external_install_contract() -> None:
package_version="v3",
release_version="1.2.3",
force_install=False,
before_dependency_install=None,
)
helper.install.assert_not_called()
@@ -72,10 +73,227 @@ async def test_package_manager_async_preserves_external_install_contract() -> No
package_version="v3",
release_version="1.2.3",
force_install=False,
before_dependency_install=None,
)
helper.async_install.assert_not_called()
@pytest.mark.asyncio
async def test_package_manager_captures_native_state_only_when_dependency_install_starts(
monkeypatch,
) -> None:
"""包适配器只在安装器确认存在依赖清单后记录原生载荷。"""
helper = Mock()
checkpoint = SimpleNamespace(native_dependencies=None)
baseline = object()
capture = Mock(return_value=baseline)
async def install_with_dependency(**kwargs):
kwargs["before_dependency_install"]()
return True, "installed"
helper._PluginHelper__async_install_package = AsyncMock(
side_effect=install_with_dependency
)
monkeypatch.setattr(
"app.adapters.system.plugin.package.capture_loaded_native_dependencies",
capture,
)
manager = PluginPackageManager(helper=helper)
result = await manager.async_install(
plugin_id="DemoPlugin",
repo_url=REPO_URL,
checkpoint=checkpoint,
)
assert result == (True, "installed")
assert checkpoint.native_dependencies is baseline
capture.assert_called_once_with()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("filename", "content"),
[
("requirements.txt", "demo>=1\n"),
(
"pyproject.toml",
'[project]\nname = "demo"\nversion = "1.0.0"\n'
'dependencies = ["demo>=1"]\n',
),
],
)
async def test_dependency_manifest_is_observed_before_install(
tmp_path,
monkeypatch,
filename,
content,
) -> None:
"""两类插件依赖清单共用同一安装前观察边界。"""
plugin_root = tmp_path / "plugins"
plugin_dir = plugin_root / "demoplugin"
plugin_dir.mkdir(parents=True)
manifest = plugin_dir / filename
manifest.write_text(content, encoding="utf-8")
helper = PluginHelper()
calls = []
async def install(path, _find_links=None):
calls.append(("install", path))
return True, ""
monkeypatch.setattr(market, "PLUGIN_DIR", plugin_root)
monkeypatch.setattr(
helper,
"_PluginHelper__async_install_packages_with_fallback",
install,
)
result = await helper._PluginHelper__async_install_dependencies_if_required(
"DemoPlugin",
lambda: calls.append(("observe", None)),
)
assert result == (True, True, "")
assert calls == [("observe", None), ("install", manifest)]
@pytest.mark.asyncio
async def test_dependency_observer_failure_does_not_block_install(
tmp_path,
monkeypatch,
) -> None:
"""原生状态观察异常不应改变插件依赖安装结果。"""
plugin_root = tmp_path / "plugins"
plugin_dir = plugin_root / "demoplugin"
plugin_dir.mkdir(parents=True)
requirements_file = plugin_dir / "requirements.txt"
requirements_file.write_text("demo>=1\n", encoding="utf-8")
helper = PluginHelper()
install = AsyncMock(return_value=(True, ""))
def failing_observer():
raise OSError("probe unavailable")
monkeypatch.setattr(market, "PLUGIN_DIR", plugin_root)
monkeypatch.setattr(
helper,
"_PluginHelper__async_install_packages_with_fallback",
install,
)
result = await helper._PluginHelper__async_install_dependencies_if_required(
"DemoPlugin",
failing_observer,
)
assert result == (True, True, "")
install.assert_awaited_once_with(requirements_file)
@pytest.mark.asyncio
async def test_dependency_observer_is_not_called_without_manifest(
tmp_path,
monkeypatch,
) -> None:
"""未声明依赖的插件不承担原生环境快照成本。"""
plugin_root = tmp_path / "plugins"
(plugin_root / "demoplugin").mkdir(parents=True)
helper = PluginHelper()
observer = Mock()
monkeypatch.setattr(market, "PLUGIN_DIR", plugin_root)
result = await helper._PluginHelper__async_install_dependencies_if_required(
"DemoPlugin",
observer,
)
assert result == (False, False, "不存在依赖")
observer.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("filename", "content"),
[
("requirements.txt", "\n# no dependencies\n"),
(
"pyproject.toml",
'[project]\nname = "demo"\nversion = "1.0.0"\n'
"dependencies = []\n",
),
],
)
async def test_empty_dependency_manifest_does_not_trigger_native_snapshot(
tmp_path,
monkeypatch,
filename,
content,
) -> None:
"""空清单保持既有安装兼容,但不枚举宿主原生发行包。"""
plugin_root = tmp_path / "plugins"
plugin_dir = plugin_root / "demoplugin"
plugin_dir.mkdir(parents=True)
manifest = plugin_dir / filename
manifest.write_text(content, encoding="utf-8")
helper = PluginHelper()
observer = Mock()
install = AsyncMock(return_value=(True, ""))
monkeypatch.setattr(market, "PLUGIN_DIR", plugin_root)
monkeypatch.setattr(
helper,
"_PluginHelper__async_install_packages_with_fallback",
install,
)
result = await helper._PluginHelper__async_install_dependencies_if_required(
"DemoPlugin",
observer,
)
assert result == (True, True, "")
observer.assert_not_called()
install.assert_awaited_once_with(manifest)
@pytest.mark.asyncio
async def test_legacy_unparsed_requirement_still_triggers_native_snapshot(
tmp_path,
monkeypatch,
) -> None:
"""历史 VCS 或选项式依赖不能因结构化解析为空而漏过观察。"""
plugin_root = tmp_path / "plugins"
plugin_dir = plugin_root / "demoplugin"
plugin_dir.mkdir(parents=True)
manifest = plugin_dir / "requirements.txt"
manifest.write_text(
"git+https://example.invalid/demo.git@fixed\n",
encoding="utf-8",
)
helper = PluginHelper()
calls = []
async def install(path, _find_links=None):
calls.append(("install", path))
return True, ""
monkeypatch.setattr(market, "PLUGIN_DIR", plugin_root)
monkeypatch.setattr(
helper,
"_PluginHelper__async_install_packages_with_fallback",
install,
)
result = await helper._PluginHelper__async_install_dependencies_if_required(
"DemoPlugin",
lambda: calls.append(("observe", None)),
)
assert result == (True, True, "")
assert calls == [("observe", None), ("install", manifest)]
def test_external_sync_helper_rejects_until_gateway_is_configured(
monkeypatch,
) -> None:
@@ -220,7 +438,11 @@ async def test_http_install_forwards_repo_url_without_granting_source_authority(
"""普通更新保留绑定仓库提示,但不能把它升级为选源授权。"""
gateway = Mock()
gateway.install = AsyncMock(
return_value=SimpleNamespace(success=True, message="")
return_value=SimpleNamespace(
success=True,
message="installed",
restart_required=True,
)
)
monkeypatch.setattr(
plugin_endpoint,
@@ -237,6 +459,7 @@ async def test_http_install_forwards_repo_url_without_granting_source_authority(
)
assert result.success is True
assert result.data.restart_required is True
gateway.install.assert_awaited_once_with(
plugin_id="DemoPlugin",
repo_url=REPO_URL,
@@ -253,7 +476,11 @@ async def test_http_explicit_source_install_uses_explicit_gateway_mode(
"""专用来源安装入口必须把管理员选择传给统一 Gateway。"""
gateway = Mock()
gateway.install = AsyncMock(
return_value=SimpleNamespace(success=True, message="")
return_value=SimpleNamespace(
success=True,
message="installed",
restart_required=True,
)
)
monkeypatch.setattr(
plugin_endpoint,
@@ -272,6 +499,7 @@ async def test_http_explicit_source_install_uses_explicit_gateway_mode(
)
assert result.success is True
assert result.data.restart_required is True
gateway.install.assert_awaited_once_with(
plugin_id="DemoPlugin",
repo_url=REPO_URL,
@@ -288,7 +516,11 @@ async def test_http_source_change_requires_revision_and_explicit_gateway_mode(
"""管理员换源入口必须把目标仓库和精确 revision 交给统一 Gateway。"""
gateway = Mock()
gateway.install = AsyncMock(
return_value=SimpleNamespace(success=True, message="")
return_value=SimpleNamespace(
success=True,
message="installed",
restart_required=True,
)
)
monkeypatch.setattr(
plugin_endpoint,
@@ -307,6 +539,7 @@ async def test_http_source_change_requires_revision_and_explicit_gateway_mode(
)
assert result.success is True
assert result.data.restart_required is True
gateway.install.assert_awaited_once_with(
plugin_id="DemoPlugin",
repo_url=REPO_URL,
@@ -444,10 +677,14 @@ def test_source_api_openapi_uses_structured_contracts() -> None:
change_schema = change_operation["requestBody"]["content"]["application/json"]["schema"]
install_schema = install_operation["requestBody"]["content"]["application/json"]["schema"]
change_response_schema = change_operation["responses"]["200"]["content"]["application/json"]["schema"]
install_response_schema = install_operation["responses"]["200"]["content"]["application/json"]["schema"]
options_schema = options_operation["responses"]["200"]["content"]["application/json"]["schema"]
assert change_schema["$ref"].endswith("/PluginSourceChangeRequest")
assert install_schema["$ref"].endswith("/PluginSourceInstallRequest")
assert change_response_schema["$ref"].endswith("/Response_PluginInstallOutcome_")
assert install_response_schema["$ref"].endswith("/Response_PluginInstallOutcome_")
assert options_schema["$ref"].endswith("/Response_PluginSourceOptions_")
+167 -1
View File
@@ -5,7 +5,7 @@ from contextlib import nullcontext
from dataclasses import replace
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from unittest.mock import ANY, AsyncMock, Mock
import pytest
@@ -33,6 +33,7 @@ from app.application.plugin.transaction import (
PluginInstallationPhase,
PluginInstallationRecord,
)
from app.runtime.native_dependencies import NativeDependencyChange
from app.schemas.exception import (
DatabaseWorkerClosedError,
PersistenceUnavailableError,
@@ -247,12 +248,14 @@ def _command(
package_finalize_backup=None,
package_commit=None,
payload_receipt=None,
native_dependency_changes=None,
reporter=None,
target_reloader=None,
rollback_reloader=None,
registration_refresher=None,
mutation=None,
package_write_guard=None,
restart_required_recorder=None,
transaction_id: str = "txn-demo",
) -> tuple[PluginInstallCommand, _PersistenceSpy, list[str]]:
"""构造只含窄端口的安装命令,并返回可观测调用记录。"""
@@ -293,6 +296,8 @@ def _command(
async_finalize_persistent_backup=package_finalize_backup or AsyncMock(),
async_commit=package_commit or AsyncMock(),
async_payload_receipt=payload_receipt or default_receipt,
async_native_dependency_changes=native_dependency_changes
or AsyncMock(return_value=()),
)
command = PluginInstallCommand(
persistence=persistence,
@@ -307,6 +312,7 @@ def _command(
mutation=mutation or (lambda _operation: nullcontext()),
package_write_guard=package_write_guard
or (lambda _plugin_id: nullcontext()),
restart_required_recorder=restart_required_recorder or Mock(),
clock=lambda: NOW,
transaction_id_factory=lambda: transaction_id,
)
@@ -456,6 +462,128 @@ async def test_non_active_runtime_status_compensates_before_database_commit():
assert persistence.records == {}
@pytest.mark.asyncio
async def test_native_dependency_change_commits_payload_when_reload_waits_for_restart():
"""原生依赖已经落盘后,重载失败不得恢复为与新依赖不匹配的旧插件。"""
change = NativeDependencyChange(
distribution="native-demo",
previous_version="1.0.0",
current_version="2.0.0",
artifacts=("native_demo.so",),
)
package_restore = AsyncMock()
package_cleanup = AsyncMock()
rollback_reloader = AsyncMock()
registration_refresher = AsyncMock()
restart_required_recorder = Mock()
command, persistence, calls = _command(
native_dependency_changes=AsyncMock(return_value=(change,)),
package_restore=package_restore,
package_cleanup=package_cleanup,
target_reloader=AsyncMock(return_value=PluginRuntimeStatus.LOAD_FAILED),
rollback_reloader=rollback_reloader,
registration_refresher=registration_refresher,
restart_required_recorder=restart_required_recorder,
)
result = await _execute(command)
assert result.success is True
assert result.package_installed is True
assert result.installed_list_persisted is True
assert result.runtime_reloaded is False
assert result.registrations_refreshed is False
assert result.restart_required is True
assert persistence.identity is not None
assert persistence.records == {}
assert "journal_commit" in calls
package_restore.assert_not_awaited()
package_cleanup.assert_not_awaited()
rollback_reloader.assert_not_awaited()
registration_refresher.assert_not_awaited()
restart_required_recorder.assert_called_once_with(
"DemoPlugin",
("native-demo",),
)
@pytest.mark.asyncio
async def test_native_dependency_change_keeps_active_plugin_available():
"""当前进程仍可重载时,插件保持 ACTIVE 并独立记录重启要求。"""
change = NativeDependencyChange(
distribution="native-demo",
previous_version="1.0.0",
current_version="2.0.0",
artifacts=("native_demo.pyd",),
)
registration_refresher = AsyncMock()
restart_required_recorder = Mock()
command, _, _ = _command(
native_dependency_changes=AsyncMock(return_value=(change,)),
target_reloader=AsyncMock(return_value=PluginRuntimeStatus.ACTIVE),
registration_refresher=registration_refresher,
restart_required_recorder=restart_required_recorder,
)
result = await _execute(command)
assert result.success is True
assert result.runtime_reloaded is True
assert result.registrations_refreshed is True
assert result.restart_required is True
registration_refresher.assert_awaited_once_with("DemoPlugin")
restart_required_recorder.assert_called_once_with(
"DemoPlugin",
("native-demo",),
)
@pytest.mark.asyncio
async def test_native_dependency_detection_failure_keeps_normal_install_result():
"""原生依赖诊断不可用时应保持普通插件安装语义。"""
restart_required_recorder = Mock()
command, _, _ = _command(
native_dependency_changes=AsyncMock(side_effect=OSError("probe unavailable")),
restart_required_recorder=restart_required_recorder,
)
result = await _execute(command)
assert result.success is True
assert result.runtime_reloaded is True
assert result.restart_required is False
restart_required_recorder.assert_not_called()
@pytest.mark.asyncio
async def test_failed_package_install_keeps_detected_native_restart_requirement():
"""依赖部分落盘后安装失败,文件补偿不能清除进程级激活要求。"""
change = NativeDependencyChange(
distribution="native-demo",
previous_version="1.0.0",
current_version="2.0.0",
artifacts=("native_demo.so",),
)
package_restore = AsyncMock()
restart_required_recorder = Mock()
command, _, _ = _command(
installer=AsyncMock(return_value=(False, "dependency failed")),
native_dependency_changes=AsyncMock(return_value=(change,)),
package_restore=package_restore,
restart_required_recorder=restart_required_recorder,
)
result = await _execute(command)
assert result.success is False
assert result.failure_stage == "package_install"
package_restore.assert_awaited_once()
restart_required_recorder.assert_called_once_with(
"DemoPlugin",
("native-demo",),
)
@pytest.mark.asyncio
async def test_existing_plugin_with_non_active_runtime_status_is_not_reported_successfully():
"""已有载荷刷新失败时不得伪装成运行态成功或发送安装上报。"""
@@ -847,6 +975,7 @@ async def test_force_install_replaces_matching_payload_and_local_sync_skips_repo
package_version="v3",
release_version=None,
force_install=True,
checkpoint=ANY,
)
reporter.assert_not_awaited()
@@ -902,6 +1031,43 @@ async def test_cancelled_package_install_waits_for_compensation():
assert persistence.records == {}
@pytest.mark.asyncio
async def test_cancelled_package_install_keeps_detected_native_restart_requirement():
"""取消传播前完成依赖检测,已替换的共享原生载荷仍要求重启。"""
started = asyncio.Event()
release = asyncio.Event()
change = NativeDependencyChange(
distribution="native-demo",
previous_version="1.0.0",
current_version="2.0.0",
artifacts=("native_demo.pyd",),
)
restart_required_recorder = Mock()
async def installer(**_kwargs):
started.set()
await release.wait()
return True, "installed"
command, _, _ = _command(
installer=installer,
native_dependency_changes=AsyncMock(return_value=(change,)),
restart_required_recorder=restart_required_recorder,
)
task = asyncio.create_task(_execute(command))
await started.wait()
task.cancel()
release.set()
with pytest.raises(asyncio.CancelledError):
await task
restart_required_recorder.assert_called_once_with(
"DemoPlugin",
("native-demo",),
)
@pytest.mark.asyncio
async def test_cancelled_journal_create_resolves_persisted_record_before_rollback():
"""PREPARED 已写入但调用被取消时,必须确认记录并完成补偿。"""
+21
View File
@@ -6,6 +6,7 @@ from unittest.mock import Mock
import pytest
from app.adapters.system.plugin.package import PluginPackageManager
from app.runtime.native_dependencies import LoadedNativeDependencySnapshot
def _manager(monkeypatch, tmp_path: Path) -> PluginPackageManager:
@@ -19,6 +20,10 @@ def _manager(monkeypatch, tmp_path: Path) -> PluginPackageManager:
"app.adapters.system.plugin.package.get_runtime_setting",
lambda key: getattr(settings, key),
)
monkeypatch.setattr(
"app.adapters.system.plugin.package.capture_loaded_native_dependencies",
LoadedNativeDependencySnapshot,
)
return PluginPackageManager(helper=Mock())
@@ -40,6 +45,22 @@ def test_checkpoint_rollback_restores_existing_package(monkeypatch, tmp_path):
assert not checkpoint.transaction_dir.exists()
def test_checkpoint_does_not_scan_native_dependencies(monkeypatch, tmp_path):
"""普通插件文件快照不应枚举宿主全部原生发行包。"""
manager = _manager(monkeypatch, tmp_path)
capture = Mock()
monkeypatch.setattr(
"app.adapters.system.plugin.package.capture_loaded_native_dependencies",
capture,
)
checkpoint = manager.checkpoint("DemoPlugin")
assert checkpoint.native_dependencies is None
capture.assert_not_called()
assert manager.native_dependency_changes(checkpoint) == ()
def test_checkpoint_rollback_removes_new_package(monkeypatch, tmp_path):
"""首次安装失败时应删除安装过程创建的不完整目录。"""
manager = _manager(monkeypatch, tmp_path)
+32
View File
@@ -58,3 +58,35 @@ def test_registry_tracks_runtime_status_generation_and_settling():
registry.remove("Demo")
assert registry.runtime_status("Demo") is None
def test_registry_tracks_restart_requirement_independently_from_runtime_status():
"""重启激活是正交维度,重复记录只合并发行包并推进真实变化。"""
registry = PluginRegistry()
registry.set_runtime_status("Demo", PluginRuntimeStatus.ACTIVE)
baseline_generation = registry.generation
registry.mark_restart_required("Demo", ("native-b", "native-a"))
changed_generation = registry.generation
registry.mark_restart_required("Demo", ("native-a",))
assert registry.runtime_status("Demo") is PluginRuntimeStatus.ACTIVE
assert registry.restart_required_snapshot() == {
"Demo": ("native-a", "native-b"),
}
assert changed_generation == baseline_generation + 1
assert registry.generation == changed_generation
registry.remove("Demo")
assert registry.restart_required_snapshot() == {}
def test_registry_clear_removes_restart_requirements():
"""整体清空注册表时不能保留已卸载插件的重启要求。"""
registry = PluginRegistry()
registry.mark_restart_required("Demo", ("native-demo",))
registry.clear()
assert registry.restart_required_snapshot() == {}