From 92066dfad4cbde244a0d3f2e0e4ed61bc4d162e0 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:10:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E4=BE=9D=E8=B5=96=E9=87=8D=E5=90=AF=E6=BF=80?= =?UTF-8?q?=E6=B4=BB=E9=97=AD=E7=8E=AF=20(#6491)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(plugins): track native dependency activation * fix(plugins): finalize native restart state lifecycle --- app/adapters/external/market.py | 170 +++++++++--- app/adapters/system/plugin/manifest.py | 23 +- app/adapters/system/plugin/package.py | 62 ++++- app/api/endpoints/plugin.py | 52 +++- app/application/plugin/install.py | 104 ++++++- app/runtime/extensions/plugin/registry.py | 33 ++- app/runtime/extensions/plugin_manager.py | 68 +++-- app/runtime/native_dependencies.py | 262 ++++++++++++++++++ app/schemas/exports.py | 1 + app/schemas/plugin.py | 12 + app/startup/initializers/plugins.py | 1 + tests/test_native_dependency_activation.py | 117 ++++++++ tests/test_plugin_endpoint.py | 15 +- .../test_plugin_external_install_boundary.py | 243 +++++++++++++++- tests/test_plugin_install_command.py | 168 ++++++++++- tests/test_plugin_package_manager.py | 21 ++ tests/test_plugin_registry.py | 32 +++ 17 files changed, 1285 insertions(+), 99 deletions(-) create mode 100644 app/runtime/native_dependencies.py create mode 100644 tests/test_native_dependency_activation.py diff --git a/app/adapters/external/market.py b/app/adapters/external/market.py index a9cd9e610..217e2188c 100644 --- a/app/adapters/external/market.py +++ b/app/adapters/external/market.py @@ -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}") diff --git a/app/adapters/system/plugin/manifest.py b/app/adapters/system/plugin/manifest.py index 6d0f092eb..b6a7cf462 100644 --- a/app/adapters/system/plugin/manifest.py +++ b/app/adapters/system/plugin/manifest.py @@ -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: diff --git a/app/adapters/system/plugin/package.py b/app/adapters/system/plugin/package.py index 96308f179..ee94f7189 100644 --- a/app/adapters/system/plugin/package.py +++ b/app/adapters/system/plugin/package.py @@ -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() diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index 2b29c653c..67862a4c4 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -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( diff --git a/app/application/plugin/install.py b/app/application/plugin/install.py index bec696101..2f8509dd0 100644 --- a/app/application/plugin/install.py +++ b/app/application/plugin/install.py @@ -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 供启动回放。""" diff --git a/app/runtime/extensions/plugin/registry.py b/app/runtime/extensions/plugin/registry.py index a6a1db398..b94a0b3f0 100644 --- a/app/runtime/extensions/plugin/registry.py +++ b/app/runtime/extensions/plugin/registry.py @@ -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 diff --git a/app/runtime/extensions/plugin_manager.py b/app/runtime/extensions/plugin_manager.py index 6e7d685a9..fd7301faf 100644 --- a/app/runtime/extensions/plugin_manager.py +++ b/app/runtime/extensions/plugin_manager.py @@ -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 diff --git a/app/runtime/native_dependencies.py b/app/runtime/native_dependencies.py new file mode 100644 index 000000000..76bb314c5 --- /dev/null +++ b/app/runtime/native_dependencies.py @@ -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))) diff --git a/app/schemas/exports.py b/app/schemas/exports.py index 90d80157d..64e1e612f 100644 --- a/app/schemas/exports.py +++ b/app/schemas/exports.py @@ -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'), diff --git a/app/schemas/plugin.py b/app/schemas/plugin.py index 1c345dd68..3cb6f3824 100644 --- a/app/schemas/plugin.py +++ b/app/schemas/plugin.py @@ -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): diff --git a/app/startup/initializers/plugins.py b/app/startup/initializers/plugins.py index 14484cc83..660d63722 100644 --- a/app/startup/initializers/plugins.py +++ b/app/startup/initializers/plugins.py @@ -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, ) diff --git a/tests/test_native_dependency_activation.py b/tests/test_native_dependency_activation.py new file mode 100644 index 000000000..edcd4d5b3 --- /dev/null +++ b/tests/test_native_dependency_activation.py @@ -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") diff --git a/tests/test_plugin_endpoint.py b/tests/test_plugin_endpoint.py index 0c4fb39d1..3274c66f7 100644 --- a/tests/test_plugin_endpoint.py +++ b/tests/test_plugin_endpoint.py @@ -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): diff --git a/tests/test_plugin_external_install_boundary.py b/tests/test_plugin_external_install_boundary.py index 8b8327abc..e1023881e 100644 --- a/tests/test_plugin_external_install_boundary.py +++ b/tests/test_plugin_external_install_boundary.py @@ -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_") diff --git a/tests/test_plugin_install_command.py b/tests/test_plugin_install_command.py index b107892b3..7abb42512 100644 --- a/tests/test_plugin_install_command.py +++ b/tests/test_plugin_install_command.py @@ -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 已写入但调用被取消时,必须确认记录并完成补偿。""" diff --git a/tests/test_plugin_package_manager.py b/tests/test_plugin_package_manager.py index 3417a8162..78244b187 100644 --- a/tests/test_plugin_package_manager.py +++ b/tests/test_plugin_package_manager.py @@ -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) diff --git a/tests/test_plugin_registry.py b/tests/test_plugin_registry.py index 23149dc72..f2a18eab3 100644 --- a/tests/test_plugin_registry.py +++ b/tests/test_plugin_registry.py @@ -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() == {}