mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-10 18:06:48 +08:00
feat: 完成插件原生依赖重启激活闭环 (#6491)
* feat(plugins): track native dependency activation * fix(plugins): finalize native restart state lifecycle
This commit is contained in:
Vendored
+130
-40
@@ -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}")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 供启动回放。"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
@@ -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'),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
@@ -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):
|
||||
|
||||
@@ -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_")
|
||||
|
||||
|
||||
|
||||
@@ -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 已写入但调用被取消时,必须确认记录并完成补偿。"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() == {}
|
||||
|
||||
Reference in New Issue
Block a user