From e61b29ec6a4a5c6b78aac256be91fa7fbb772f55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=A8=E3=82=A4=E3=82=AB=E3=82=AF?= <62183434+zouyonghe@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:01:44 +0900 Subject: [PATCH] fix: harden plugin dependency loading in frozen app runtime (#5015) * fix: compare plugin versions semantically in market updates * fix: prioritize plugin site-packages for in-process pip * fix: reload starlette from plugin target site-packages * fix: harden plugin dependency import precedence in frozen runtime * fix: improve plugin dependency conflict handling * refactor: simplify plugin conflict checks and version utils * fix: expand transitive plugin dependencies for conflict checks * fix: recover conflicting plugin dependencies during module prefer * fix: reuse renderer restart flow for tray backend restart * fix: add recoverable plugin dependency conflict handling * revert: remove plugin version comparison changes * fix: add missing tray restart backend labels --- astrbot/core/star/star_manager.py | 273 ++++++++++------ astrbot/core/utils/pip_installer.py | 394 ++++++++++++++++++++++- dashboard/src/App.vue | 29 +- dashboard/src/types/electron-bridge.d.ts | 1 + desktop/lib/locale-service.js | 2 + desktop/main.js | 23 ++ desktop/preload.js | 10 + 7 files changed, 630 insertions(+), 102 deletions(-) diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index 2c8c940f2..587808956 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -191,6 +191,7 @@ class PluginManager: await pip_installer.install(requirements_path=pth) except Exception as e: logger.error(f"更新插件 {p} 的依赖失败。Code: {e!s}") + return True @staticmethod def _load_plugin_metadata(plugin_path: str, plugin_obj=None) -> StarMetadata | None: @@ -644,6 +645,49 @@ class PluginManager: self.failed_plugin_info = fail_rec return False, fail_rec + async def _cleanup_failed_plugin_install( + self, + dir_name: str, + plugin_path: str, + ) -> None: + plugin = None + for star in self.context.get_all_stars(): + if star.root_dir_name == dir_name: + plugin = star + break + + if plugin and plugin.name and plugin.module_path: + try: + await self._terminate_plugin(plugin) + except Exception: + logger.warning(traceback.format_exc()) + try: + await self._unbind_plugin(plugin.name, plugin.module_path) + except Exception: + logger.warning(traceback.format_exc()) + + if os.path.exists(plugin_path): + try: + remove_dir(plugin_path) + logger.warning(f"已清理安装失败的插件目录: {plugin_path}") + except Exception as e: + logger.warning( + f"清理安装失败插件目录失败: {plugin_path},原因: {e!s}", + ) + + plugin_config_path = os.path.join( + self.plugin_config_path, + f"{dir_name}_config.json", + ) + if os.path.exists(plugin_config_path): + try: + os.remove(plugin_config_path) + logger.warning(f"已清理安装失败插件配置: {plugin_config_path}") + except Exception as e: + logger.warning( + f"清理安装失败插件配置失败: {plugin_config_path},原因: {e!s}", + ) + async def install_plugin(self, repo_url: str, proxy=""): """从仓库 URL 安装插件 @@ -669,44 +713,62 @@ class PluginManager: ) async with self._pm_lock: - plugin_path = await self.updator.install(repo_url, proxy) - # reload the plugin - dir_name = os.path.basename(plugin_path) - await self.load(specified_dir_name=dir_name) + plugin_path = "" + dir_name = "" + cleanup_required = False + try: + plugin_path = await self.updator.install(repo_url, proxy) + cleanup_required = True - # Get the plugin metadata to return repo info - plugin = self.context.get_registered_star(dir_name) - if not plugin: - # Try to find by other name if directory name doesn't match plugin name - for star in self.context.get_all_stars(): - if star.root_dir_name == dir_name: - plugin = star - break - - # Extract README.md content if exists - readme_content = None - readme_path = os.path.join(plugin_path, "README.md") - if not os.path.exists(readme_path): - readme_path = os.path.join(plugin_path, "readme.md") - - if os.path.exists(readme_path): - try: - with open(readme_path, encoding="utf-8") as f: - readme_content = f.read() - except Exception as e: - logger.warning( - f"读取插件 {dir_name} 的 README.md 文件失败: {e!s}", + # reload the plugin + dir_name = os.path.basename(plugin_path) + success, error_message = await self.load(specified_dir_name=dir_name) + if not success: + raise Exception( + error_message + or f"安装插件 {dir_name} 失败,请检查插件依赖或兼容性。" ) - plugin_info = None - if plugin: - plugin_info = { - "repo": plugin.repo, - "readme": readme_content, - "name": plugin.name, - } + # Get the plugin metadata to return repo info + plugin = self.context.get_registered_star(dir_name) + if not plugin: + # Try to find by other name if directory name doesn't match plugin name + for star in self.context.get_all_stars(): + if star.root_dir_name == dir_name: + plugin = star + break - return plugin_info + # Extract README.md content if exists + readme_content = None + readme_path = os.path.join(plugin_path, "README.md") + if not os.path.exists(readme_path): + readme_path = os.path.join(plugin_path, "readme.md") + + if os.path.exists(readme_path): + try: + with open(readme_path, encoding="utf-8") as f: + readme_content = f.read() + except Exception as e: + logger.warning( + f"读取插件 {dir_name} 的 README.md 文件失败: {e!s}", + ) + + plugin_info = None + if plugin: + plugin_info = { + "repo": plugin.repo, + "readme": readme_content, + "name": plugin.name, + } + + return plugin_info + except Exception: + if cleanup_required and dir_name and plugin_path: + await self._cleanup_failed_plugin_install( + dir_name=dir_name, + plugin_path=plugin_path, + ) + raise async def uninstall_plugin( self, @@ -968,6 +1030,7 @@ class PluginManager: dir_name = os.path.basename(zip_file_path).replace(".zip", "") dir_name = dir_name.removesuffix("-master").removesuffix("-main").lower() desti_dir = os.path.join(self.plugin_store_path, dir_name) + cleanup_required = False # 第一步:检查是否已安装同目录名的插件,先终止旧插件 existing_plugin = None @@ -987,74 +1050,88 @@ class PluginManager: existing_plugin.name, existing_plugin.module_path ) - self.updator.unzip_file(zip_file_path, desti_dir) - - # 第二步:解压后,读取新插件的 metadata.yaml,检查是否存在同名但不同目录的插件 try: - new_metadata = self._load_plugin_metadata(desti_dir) - if new_metadata and new_metadata.name: - for star in self.context.get_all_stars(): - if ( - star.name == new_metadata.name - and star.root_dir_name != dir_name - ): - logger.warning( - f"检测到同名插件 {star.name} 存在于不同目录 {star.root_dir_name},正在终止..." - ) - try: - await self._terminate_plugin(star) - except Exception: - logger.warning(traceback.format_exc()) - if star.name and star.module_path: - await self._unbind_plugin(star.name, star.module_path) - break # 只处理第一个匹配的 - except Exception as e: - logger.debug(f"读取新插件 metadata.yaml 失败,跳过同名检查: {e!s}") + self.updator.unzip_file(zip_file_path, desti_dir) + cleanup_required = True - # remove the zip - try: - os.remove(zip_file_path) - except BaseException as e: - logger.warning(f"删除插件压缩包失败: {e!s}") - # await self.reload() - await self.load(specified_dir_name=dir_name) - - # Get the plugin metadata to return repo info - plugin = self.context.get_registered_star(dir_name) - if not plugin: - # Try to find by other name if directory name doesn't match plugin name - for star in self.context.get_all_stars(): - if star.root_dir_name == dir_name: - plugin = star - break - - # Extract README.md content if exists - readme_content = None - readme_path = os.path.join(desti_dir, "README.md") - if not os.path.exists(readme_path): - readme_path = os.path.join(desti_dir, "readme.md") - - if os.path.exists(readme_path): + # 第二步:解压后,读取新插件的 metadata.yaml,检查是否存在同名但不同目录的插件 try: - with open(readme_path, encoding="utf-8") as f: - readme_content = f.read() + new_metadata = self._load_plugin_metadata(desti_dir) + if new_metadata and new_metadata.name: + for star in self.context.get_all_stars(): + if ( + star.name == new_metadata.name + and star.root_dir_name != dir_name + ): + logger.warning( + f"检测到同名插件 {star.name} 存在于不同目录 {star.root_dir_name},正在终止..." + ) + try: + await self._terminate_plugin(star) + except Exception: + logger.warning(traceback.format_exc()) + if star.name and star.module_path: + await self._unbind_plugin(star.name, star.module_path) + break # 只处理第一个匹配的 except Exception as e: - logger.warning(f"读取插件 {dir_name} 的 README.md 文件失败: {e!s}") + logger.debug(f"读取新插件 metadata.yaml 失败,跳过同名检查: {e!s}") - plugin_info = None - if plugin: - plugin_info = { - "repo": plugin.repo, - "readme": readme_content, - "name": plugin.name, - } - - if plugin.repo: - asyncio.create_task( - Metric.upload( - et="install_star_f", # install star - repo=plugin.repo, - ), + # remove the zip + try: + os.remove(zip_file_path) + except BaseException as e: + logger.warning(f"删除插件压缩包失败: {e!s}") + # await self.reload() + success, error_message = await self.load(specified_dir_name=dir_name) + if not success: + raise Exception( + error_message + or f"安装插件 {dir_name} 失败,请检查插件依赖或兼容性。" ) - return plugin_info + # Get the plugin metadata to return repo info + plugin = self.context.get_registered_star(dir_name) + if not plugin: + # Try to find by other name if directory name doesn't match plugin name + for star in self.context.get_all_stars(): + if star.root_dir_name == dir_name: + plugin = star + break + + # Extract README.md content if exists + readme_content = None + readme_path = os.path.join(desti_dir, "README.md") + if not os.path.exists(readme_path): + readme_path = os.path.join(desti_dir, "readme.md") + + if os.path.exists(readme_path): + try: + with open(readme_path, encoding="utf-8") as f: + readme_content = f.read() + except Exception as e: + logger.warning(f"读取插件 {dir_name} 的 README.md 文件失败: {e!s}") + + plugin_info = None + if plugin: + plugin_info = { + "repo": plugin.repo, + "readme": readme_content, + "name": plugin.name, + } + + if plugin.repo: + asyncio.create_task( + Metric.upload( + et="install_star_f", # install star + repo=plugin.repo, + ), + ) + + return plugin_info + except Exception: + if cleanup_required: + await self._cleanup_failed_plugin_install( + dir_name=dir_name, + plugin_path=desti_dir, + ) + raise diff --git a/astrbot/core/utils/pip_installer.py b/astrbot/core/utils/pip_installer.py index 6be259a1d..8d43c11b2 100644 --- a/astrbot/core/utils/pip_installer.py +++ b/astrbot/core/utils/pip_installer.py @@ -1,10 +1,15 @@ import asyncio import contextlib import importlib +import importlib.metadata as importlib_metadata +import importlib.util import io import logging import os +import re import sys +import threading +from collections import deque from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path from astrbot.core.utils.runtime_env import is_packaged_electron_runtime @@ -12,6 +17,11 @@ from astrbot.core.utils.runtime_env import is_packaged_electron_runtime logger = logging.getLogger("astrbot") _DISTLIB_FINDER_PATCH_ATTEMPTED = False +_SITE_PACKAGES_IMPORT_LOCK = threading.RLock() + + +def _canonicalize_distribution_name(name: str) -> str: + return re.sub(r"[-_.]+", "-", name).strip("-").lower() def _get_pip_main(): @@ -49,6 +59,373 @@ def _cleanup_added_root_handlers(original_handlers: list[logging.Handler]) -> No handler.close() +def _prepend_sys_path(path: str) -> None: + normalized_target = os.path.realpath(path) + sys.path[:] = [ + item for item in sys.path if os.path.realpath(item) != normalized_target + ] + sys.path.insert(0, normalized_target) + + +def _module_exists_in_site_packages(module_name: str, site_packages_path: str) -> bool: + base_path = os.path.join(site_packages_path, *module_name.split(".")) + package_init = os.path.join(base_path, "__init__.py") + module_file = f"{base_path}.py" + return os.path.isfile(package_init) or os.path.isfile(module_file) + + +def _is_module_loaded_from_site_packages( + module_name: str, + site_packages_path: str, +) -> bool: + module = sys.modules.get(module_name) + if module is None: + try: + module = importlib.import_module(module_name) + except Exception: + return False + + module_file = getattr(module, "__file__", None) + if not module_file: + return False + + module_path = os.path.realpath(module_file) + site_packages_real = os.path.realpath(site_packages_path) + try: + return ( + os.path.commonpath([module_path, site_packages_real]) == site_packages_real + ) + except ValueError: + return False + + +def _extract_requirement_name(raw_requirement: str) -> str | None: + line = raw_requirement.split("#", 1)[0].strip() + if not line: + return None + if line.startswith(("-r", "--requirement", "-c", "--constraint")): + return None + if line.startswith("-"): + return None + + egg_match = re.search(r"#egg=([A-Za-z0-9_.-]+)", raw_requirement) + if egg_match: + return _canonicalize_distribution_name(egg_match.group(1)) + + candidate = re.split(r"[<>=!~;\s\[]", line, maxsplit=1)[0].strip() + if not candidate: + return None + return _canonicalize_distribution_name(candidate) + + +def _extract_requirement_names(requirements_path: str) -> set[str]: + names: set[str] = set() + try: + with open(requirements_path, encoding="utf-8") as requirements_file: + for line in requirements_file: + requirement_name = _extract_requirement_name(line) + if requirement_name: + names.add(requirement_name) + except Exception as exc: + logger.warning("读取依赖文件失败,跳过冲突检测: %s", exc) + return names + + +def _extract_top_level_modules( + distribution: importlib_metadata.Distribution, +) -> set[str]: + try: + text = distribution.read_text("top_level.txt") or "" + except Exception: + return set() + + modules: set[str] = set() + for line in text.splitlines(): + candidate = line.strip() + if not candidate or candidate.startswith("#"): + continue + modules.add(candidate) + return modules + + +def _collect_candidate_modules( + requirement_names: set[str], + site_packages_path: str, +) -> set[str]: + by_name: dict[str, list[importlib_metadata.Distribution]] = {} + try: + for distribution in importlib_metadata.distributions(path=[site_packages_path]): + distribution_name = distribution.metadata.get("Name") + if not distribution_name: + continue + canonical_name = _canonicalize_distribution_name(distribution_name) + by_name.setdefault(canonical_name, []).append(distribution) + except Exception as exc: + logger.warning("读取 site-packages 元数据失败,使用回退模块名: %s", exc) + + expanded_requirement_names: set[str] = set() + pending = deque(requirement_names) + while pending: + requirement_name = pending.popleft() + if requirement_name in expanded_requirement_names: + continue + expanded_requirement_names.add(requirement_name) + + for distribution in by_name.get(requirement_name, []): + for dependency_line in distribution.requires or []: + dependency_name = _extract_requirement_name(dependency_line) + if not dependency_name: + continue + if dependency_name in expanded_requirement_names: + continue + pending.append(dependency_name) + + candidates: set[str] = set() + for requirement_name in expanded_requirement_names: + matched_distributions = by_name.get(requirement_name, []) + modules_for_requirement: set[str] = set() + for distribution in matched_distributions: + modules_for_requirement.update(_extract_top_level_modules(distribution)) + + if modules_for_requirement: + candidates.update(modules_for_requirement) + continue + + fallback_module_name = requirement_name.replace("-", "_") + if fallback_module_name: + candidates.add(fallback_module_name) + + return candidates + + +def _ensure_preferred_modules( + module_names: set[str], + site_packages_path: str, +) -> None: + unresolved_prefer_reasons = _prefer_modules_from_site_packages( + module_names, site_packages_path + ) + + unresolved_modules: list[str] = [] + for module_name in sorted(module_names): + if not _module_exists_in_site_packages(module_name, site_packages_path): + continue + if _is_module_loaded_from_site_packages(module_name, site_packages_path): + continue + + failure_reason = unresolved_prefer_reasons.get(module_name) + if failure_reason: + unresolved_modules.append(f"{module_name} -> {failure_reason}") + continue + + loaded_module = sys.modules.get(module_name) + loaded_from = getattr(loaded_module, "__file__", "unknown") + unresolved_modules.append(f"{module_name} -> {loaded_from}") + + if unresolved_modules: + conflict_message = ( + "检测到插件依赖与当前运行时发生冲突,无法安全加载该插件。" + f"冲突模块: {', '.join(unresolved_modules)}" + ) + raise RuntimeError(conflict_message) + + +def _prefer_module_from_site_packages( + module_name: str, site_packages_path: str +) -> bool: + with _SITE_PACKAGES_IMPORT_LOCK: + base_path = os.path.join(site_packages_path, *module_name.split(".")) + package_init = os.path.join(base_path, "__init__.py") + module_file = f"{base_path}.py" + + module_location = None + submodule_search_locations = None + + if os.path.isfile(package_init): + module_location = package_init + submodule_search_locations = [os.path.dirname(package_init)] + elif os.path.isfile(module_file): + module_location = module_file + else: + return False + + spec = importlib.util.spec_from_file_location( + module_name, + module_location, + submodule_search_locations=submodule_search_locations, + ) + if spec is None or spec.loader is None: + return False + + matched_keys = [ + key + for key in list(sys.modules.keys()) + if key == module_name or key.startswith(f"{module_name}.") + ] + original_modules = {key: sys.modules[key] for key in matched_keys} + + try: + for key in matched_keys: + sys.modules.pop(key, None) + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + + if "." in module_name: + parent_name, child_name = module_name.rsplit(".", 1) + parent_module = sys.modules.get(parent_name) + if parent_module is not None: + setattr(parent_module, child_name, module) + + logger.info( + "Loaded %s from plugin site-packages: %s", + module_name, + module_location, + ) + return True + except Exception: + failed_keys = [ + key + for key in list(sys.modules.keys()) + if key == module_name or key.startswith(f"{module_name}.") + ] + for key in failed_keys: + sys.modules.pop(key, None) + sys.modules.update(original_modules) + raise + + +def _extract_conflicting_module_name(exc: Exception) -> str | None: + if isinstance(exc, ModuleNotFoundError): + missing_name = getattr(exc, "name", None) + if missing_name: + return missing_name.split(".", 1)[0] + + message = str(exc) + from_match = re.search(r"from '([A-Za-z0-9_.]+)'", message) + if from_match: + return from_match.group(1).split(".", 1)[0] + + no_module_match = re.search(r"No module named '([A-Za-z0-9_.]+)'", message) + if no_module_match: + return no_module_match.group(1).split(".", 1)[0] + + return None + + +def _prefer_module_with_dependency_recovery( + module_name: str, + site_packages_path: str, + max_attempts: int = 3, +) -> bool: + recovered_dependencies: set[str] = set() + + for _ in range(max_attempts): + try: + return _prefer_module_from_site_packages(module_name, site_packages_path) + except Exception as exc: + dependency_name = _extract_conflicting_module_name(exc) + if ( + not dependency_name + or dependency_name == module_name + or dependency_name in recovered_dependencies + ): + raise + + recovered_dependencies.add(dependency_name) + recovered = _prefer_module_from_site_packages( + dependency_name, + site_packages_path, + ) + if not recovered: + raise + logger.info( + "Recovered dependency %s while preferring %s from plugin site-packages.", + dependency_name, + module_name, + ) + + return False + + +def _prefer_modules_from_site_packages( + module_names: set[str], + site_packages_path: str, +) -> dict[str, str]: + pending_modules = sorted(module_names) + unresolved_reasons: dict[str, str] = {} + max_rounds = max(2, min(6, len(pending_modules) + 1)) + + for _ in range(max_rounds): + if not pending_modules: + break + + next_round_pending: list[str] = [] + round_progress = False + + for module_name in pending_modules: + try: + loaded = _prefer_module_with_dependency_recovery( + module_name, + site_packages_path, + ) + except Exception as exc: + unresolved_reasons[module_name] = str(exc) + next_round_pending.append(module_name) + continue + + unresolved_reasons.pop(module_name, None) + if loaded: + round_progress = True + else: + logger.debug( + "Module %s not found in plugin site-packages: %s", + module_name, + site_packages_path, + ) + + if not next_round_pending: + pending_modules = [] + break + + if not round_progress and len(next_round_pending) == len(pending_modules): + pending_modules = next_round_pending + break + + pending_modules = next_round_pending + + final_unresolved = { + module_name: unresolved_reasons.get(module_name, "unknown import error") + for module_name in pending_modules + } + for module_name, reason in final_unresolved.items(): + logger.warning( + "Failed to prefer module %s from plugin site-packages: %s", + module_name, + reason, + ) + + return final_unresolved + + +def _ensure_plugin_dependencies_preferred( + target_site_packages: str, + requested_requirements: set[str], +) -> None: + if not requested_requirements: + return + + candidate_modules = _collect_candidate_modules( + requested_requirements, + target_site_packages, + ) + if not candidate_modules: + return + + _ensure_preferred_modules(candidate_modules, target_site_packages) + + def _get_loader_for_package(package: object) -> object | None: loader = getattr(package, "__loader__", None) if loader is not None: @@ -73,7 +450,7 @@ def _try_register_distlib_finder( return False try: - register_finder(loader_type, resource_finder) + register_finder(loader, resource_finder) except Exception as exc: logger.warning( "Failed to patch pip distlib finder for loader %s (%s): %s", @@ -165,10 +542,15 @@ class PipInstaller: mirror: str | None = None, ) -> None: args = ["install"] + requested_requirements: set[str] = set() if package_name: args.append(package_name) + requirement_name = _extract_requirement_name(package_name) + if requirement_name: + requested_requirements.add(requirement_name) elif requirements_path: args.extend(["-r", requirements_path]) + requested_requirements = _extract_requirement_names(requirements_path) index_url = mirror or self.pypi_index_url or "https://pypi.org/simple" args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", index_url]) @@ -177,7 +559,9 @@ class PipInstaller: if is_packaged_electron_runtime(): target_site_packages = get_astrbot_site_packages_path() os.makedirs(target_site_packages, exist_ok=True) + _prepend_sys_path(target_site_packages) args.extend(["--target", target_site_packages]) + args.extend(["--upgrade", "--force-reinstall"]) if self.pip_install_arg: args.extend(self.pip_install_arg.split()) @@ -188,8 +572,12 @@ class PipInstaller: if result_code != 0: raise Exception(f"安装失败,错误码:{result_code}") - if target_site_packages and target_site_packages not in sys.path: - sys.path.insert(0, target_site_packages) + if target_site_packages: + _prepend_sys_path(target_site_packages) + _ensure_plugin_dependencies_preferred( + target_site_packages, + requested_requirements, + ) importlib.invalidate_caches() async def _run_pip_in_process(self, args: list[str]) -> int: diff --git a/dashboard/src/App.vue b/dashboard/src/App.vue index 8f2b8e7b3..b580162d2 100644 --- a/dashboard/src/App.vue +++ b/dashboard/src/App.vue @@ -1,5 +1,6 @@