fix(plugin): 收敛市场安装与运行态错误处理 (#6470)

This commit is contained in:
InfinityPacer
2026-08-26 18:00:49 +08:00
committed by GitHub
parent 9dbe424c3d
commit 7c81fb357f
14 changed files with 579 additions and 80 deletions
+54 -35
View File
@@ -861,7 +861,7 @@ class PluginHelper(metaclass=WeakSingleton):
releases.extend(cls.__normalize_plugin_release_response(payload))
return len(payload) >= 100
@cached(maxsize=128, ttl=1800) # type: ignore[misc] # 缓存装饰器暂未提供泛型签名
@cached(maxsize=1024, ttl=1800, skip_none=False) # type: ignore[misc]
def get_plugin_index_result(
self,
repo_url: str,
@@ -884,7 +884,6 @@ class PluginHelper(metaclass=WeakSingleton):
raise RuntimeError("插件索引响应格式无效")
return payload
@cached(maxsize=128, ttl=1800)
def get_plugins(self, repo_url: str,
package_version: Optional[str] = None) -> Optional[Dict[str, dict]]:
"""
@@ -892,16 +891,13 @@ class PluginHelper(metaclass=WeakSingleton):
:param repo_url: Github仓库地址
:param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本
"""
request = self._build_plugin_index_request(repo_url, package_version)
if request is None:
try:
payload = self.get_plugin_index_result(repo_url, package_version)
except (ValueError, RuntimeError):
return None
package_url, headers = request
res = self.__request_with_fallback(package_url, headers=headers)
if res is None:
return None
return self._resolve_plugin_index_response(res.status_code, res.text)
return payload if payload is not None else {}
@cached(maxsize=32, ttl=1800, shared_key="get_plugin_repo_releases")
@cached(maxsize=256, ttl=1800, shared_key="get_plugin_repo_releases")
def _get_plugin_repo_releases(self, repo_url: str) -> Optional[List[dict]]:
"""
按仓库获取 GitHub Release 原始分页数据,供仓库内所有插件共享。
@@ -1137,12 +1133,12 @@ class PluginHelper(metaclass=WeakSingleton):
return True, msg
logger.warning(f"{pid} Release 安装失败,回退文件列表安装:{msg}")
self.__remove_old_plugin(pid)
return self.__prepare_content_via_filelist_sync(pid.lower(), user_repo, package_version)
return self.__prepare_content_via_filelist_sync(pid, user_repo, package_version)
return self.__install_flow_sync(pid, force_install, prepare_release, repo_url)
# 未声明 release 打包的插件继续使用文件列表方式安装。
def prepare_filelist() -> Tuple[bool, str]:
return self.__prepare_content_via_filelist_sync(pid.lower(), user_repo, package_version)
return self.__prepare_content_via_filelist_sync(pid, user_repo, package_version)
return self.__install_flow_sync(pid, force_install, prepare_filelist, repo_url)
@@ -1236,6 +1232,8 @@ class PluginHelper(metaclass=WeakSingleton):
timeout=30)
if res is None:
return None, "连接仓库失败"
elif res.status_code == 404:
return None, "插件源码目录不存在"
elif res.status_code != 200:
return None, f"连接仓库失败:{res.status_code} - " \
f"{'超出速率限制,请设置Github Token或稍后重试' if res.status_code == 403 else res.reason}"
@@ -2385,8 +2383,12 @@ class PluginHelper(metaclass=WeakSingleton):
timeout=30,
is_api=True,
)
if rel_res is None or rel_res.status_code != 200:
return False, f"获取 Release 信息失败:{rel_res.status_code if rel_res else '连接失败'}"
if rel_res is None:
return False, "获取 Release 信息失败:连接失败"
if rel_res.status_code == 404:
return False, f"{release_tag} 插件发布包不存在"
if rel_res.status_code != 200:
return False, f"获取 Release 信息失败:{rel_res.status_code}"
try:
rel_json = rel_res.json()
@@ -2550,7 +2552,7 @@ class PluginHelper(metaclass=WeakSingleton):
logger.error(f"[GitHub] 所有策略均请求失败,URL: {url},请检查网络连接或 GitHub 配置")
return None
@cached(maxsize=128, ttl=1800) # type: ignore[misc] # 缓存装饰器暂未提供泛型签名
@cached(maxsize=1024, ttl=1800, skip_none=False) # type: ignore[misc]
async def async_get_plugin_index_result(
self,
repo_url: str,
@@ -2576,7 +2578,6 @@ class PluginHelper(metaclass=WeakSingleton):
raise RuntimeError("插件索引响应格式无效")
return payload
@cached(maxsize=128, ttl=1800)
async def async_get_plugins(self, repo_url: str,
package_version: Optional[str] = None) -> Optional[Dict[str, dict]]:
"""
@@ -2584,19 +2585,16 @@ class PluginHelper(metaclass=WeakSingleton):
:param repo_url: Github仓库地址
:param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本
"""
request = self._build_plugin_index_request(repo_url, package_version)
if request is None:
try:
payload = await self.async_get_plugin_index_result(
repo_url,
package_version,
)
except (ValueError, RuntimeError):
return None
package_url, headers = request
res = await self.__async_request_with_fallback(
package_url,
headers=headers,
)
if res is None:
return None
return self._resolve_plugin_index_response(res.status_code, res.text)
return payload if payload is not None else {}
@cached(maxsize=32, ttl=1800, shared_key="get_plugin_repo_releases")
@cached(maxsize=256, ttl=1800, shared_key="get_plugin_repo_releases")
async def _async_get_plugin_repo_releases(self, repo_url: str) -> Optional[List[dict]]:
"""
异步按仓库获取 GitHub Release 原始分页数据。
@@ -2725,6 +2723,8 @@ class PluginHelper(metaclass=WeakSingleton):
timeout=30)
if res is None:
return None, "连接仓库失败"
elif res.status_code == 404:
return None, "插件源码目录不存在"
elif res.status_code != 200:
return None, f"连接仓库失败:{res.status_code} - " \
f"{'超出速率限制,请设置Github Token或稍后重试' if res.status_code == 403 else res.text}"
@@ -3287,12 +3287,12 @@ class PluginHelper(metaclass=WeakSingleton):
return True, msg
logger.warning(f"{pid} Release 安装失败,回退文件列表安装:{msg}")
await self.__async_remove_old_plugin(pid)
return await self.__prepare_content_via_filelist_async(pid.lower(), user_repo, package_version)
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)
# 未声明 release 打包的插件继续使用文件列表方式安装。
async def prepare_filelist() -> Tuple[bool, str]:
return await self.__prepare_content_via_filelist_async(pid.lower(), user_repo, package_version)
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)
@@ -3362,10 +3362,13 @@ class PluginHelper(metaclass=WeakSingleton):
"""
同步准备插件内容,通过文件列表获取插件文件和依赖
"""
file_list, msg = self.__get_file_list(pid, user_repo, package_version)
runtime_pid = pid.lower()
file_list, msg = self.__get_file_list(runtime_pid, user_repo, package_version)
if not file_list:
if msg == "插件源码目录不存在":
return False, f"{pid} {msg}"
return False, msg
ok, m = self.__download_files(pid, file_list, user_repo, package_version)
ok, m = self.__download_files(runtime_pid, file_list, user_repo, package_version)
if not ok:
return False, m
return True, ""
@@ -3375,10 +3378,22 @@ class PluginHelper(metaclass=WeakSingleton):
"""
异步准备插件内容,通过文件列表获取插件文件和依赖
"""
file_list, msg = await self.__async_get_file_list(pid, user_repo, package_version)
runtime_pid = pid.lower()
file_list, msg = await self.__async_get_file_list(
runtime_pid,
user_repo,
package_version,
)
if not file_list:
if msg == "插件源码目录不存在":
return False, f"{pid} {msg}"
return False, msg
ok, m = await self.__async_download_files(pid, file_list, user_repo, package_version)
ok, m = await self.__async_download_files(
runtime_pid,
file_list,
user_repo,
package_version,
)
if not ok:
return False, m
return True, ""
@@ -3399,8 +3414,12 @@ class PluginHelper(metaclass=WeakSingleton):
timeout=30,
is_api=True,
)
if rel_res is None or rel_res.status_code != 200:
return False, f"获取 Release 信息失败:{rel_res.status_code if rel_res else '连接失败'}"
if rel_res is None:
return False, "获取 Release 信息失败:连接失败"
if rel_res.status_code == 404:
return False, f"{release_tag} 插件发布包不存在"
if rel_res.status_code != 200:
return False, f"获取 Release 信息失败:{rel_res.status_code}"
try:
rel_json = rel_res.json()
+12 -3
View File
@@ -23,11 +23,12 @@ from app.schemas.exception import (
PersistenceUnavailableError,
PluginMutationRejectedError,
)
from app.schemas.plugin import PluginRuntimeStatus
InstalledPluginsReader = Callable[[], list[str]]
PluginIdsProvider = Callable[[], list[str]]
InstallReporter = Callable[[str, str | None], Awaitable[object]]
PluginReloader = Callable[[str], Awaitable[object]]
PluginReloader = Callable[[str], Awaitable[PluginRuntimeStatus]]
PluginRegistrationRefresher = Callable[[str], Awaitable[object]]
PluginMutationAdmission = Callable[[str], ContextManager[None]]
PluginPackageWriteGuard = Callable[[str], ContextManager[None]]
@@ -380,7 +381,7 @@ class PluginInstallCommand:
state.stage = "runtime_reload"
state.runtime_touched = True
await self.__await_side_effect(self.__target_reloader(plugin_id))
await self.__reload_active(plugin_id)
state.stage = "registration_refresh"
state.registrations_touched = True
await self.__await_side_effect(
@@ -511,7 +512,7 @@ class PluginInstallCommand:
"""刷新已经提交且载荷事实未变化的插件运行态。"""
failure_stage = "runtime_reload"
try:
await self.__await_side_effect(self.__target_reloader(plugin_id))
await self.__reload_active(plugin_id)
failure_stage = "registration_refresh"
await self.__await_side_effect(
self.__registration_refresher(plugin_id)
@@ -542,6 +543,14 @@ class PluginInstallCommand:
report_error=report_error,
)
async def __reload_active(self, plugin_id: str) -> None:
"""重载只有进入 ACTIVE 才能作为安装或刷新成功继续提交。"""
runtime_status = await self.__await_side_effect(
self.__target_reloader(plugin_id)
)
if runtime_status is not PluginRuntimeStatus.ACTIVE:
raise RuntimeError("插件加载失败,请查看插件日志")
async def __finish_committed(self, state: _InstallState) -> str:
"""幂等清理 COMMITTED 事务;失败时保留 journal 供启动回放。"""
try:
+1 -1
View File
@@ -53,7 +53,7 @@ class PluginCandidateInventoryReader:
local_candidate_loader: LocalCandidateLoader | None = None,
async_market_loader: AsyncMarketLoader | None = None,
generations: Sequence[str] = PLUGIN_V3_GENERATIONS,
max_concurrency: int = 12,
max_concurrency: int = 24,
) -> None:
"""保存读取端口,并限制异步市场请求的进程内并发。"""
normalized_generations = tuple(
+5 -1
View File
@@ -689,7 +689,7 @@ def list_effective_online_candidates(
plugin_id: str,
generations: Sequence[str],
) -> tuple[PluginMarketCandidate, ...]:
"""按来源列出当前运行代际实际可安装的最高版本候选。"""
"""按来源列出当前运行代际实际可安装的最高版本候选,官方来源始终置顶"""
generation_order = _normalize_generation_order(generations)
grouped: dict[
tuple[TrustedPluginSourceType, str],
@@ -706,6 +706,10 @@ def list_effective_online_candidates(
selected_candidate = _select_best(candidates, generation_order)
if isinstance(selected_candidate, PluginMarketCandidate):
selected.append(selected_candidate)
selected.sort(
key=lambda candidate: candidate.source_type
is not TrustedPluginSourceType.OFFICIAL
)
return tuple(selected)
+7
View File
@@ -114,6 +114,13 @@ def run_api_server() -> None:
host=get_runtime_setting('HOST'),
port=get_runtime_setting('PORT'),
reload=get_runtime_setting('DEV'),
# 运行插件及其恢复材料由插件生命周期管理,不属于宿主源码变更。
reload_excludes=[
str(get_runtime_setting('ROOT_PATH') / "app" / "plugins"),
str(get_runtime_setting('CONFIG_PATH')),
]
if get_runtime_setting('DEV')
else None,
workers=get_runtime_setting('API_WORKERS'),
timeout_graceful_shutdown=60,
)
+31 -14
View File
@@ -108,15 +108,15 @@ class PluginLifecycle:
plugins.sort(key=lambda item: getattr(item, "plugin_order", 0))
for plugin in plugins:
current_id = plugin.__name__
if plugin_id and current_id != plugin_id:
if plugin_id and current_id.casefold() != plugin_id.casefold():
continue
try:
if not self._auth_checker(plugin):
if current_id in self._classes:
self._classes[current_id] = plugin
status = PluginRuntimeStatus.BLOCKED_BY_POLICY
self._runtime_status_writer(current_id, status)
results[current_id] = status
self._runtime_status_writer(plugin_id or current_id, status)
results[plugin_id or current_id] = status
continue
self._classes[current_id] = plugin
instance = plugin()
@@ -131,16 +131,19 @@ class PluginLifecycle:
else:
self._disable_events(plugin)
status = PluginRuntimeStatus.ACTIVE
self._runtime_status_writer(current_id, status)
results[current_id] = status
self._runtime_status_writer(plugin_id or current_id, status)
results[plugin_id or current_id] = status
except Exception as error: # noqa: BLE001
status = PluginRuntimeStatus.LOAD_FAILED
self._runtime_status_writer(current_id, status)
results[current_id] = status
self._runtime_status_writer(plugin_id or current_id, status)
results[plugin_id or current_id] = status
self._logger.error(
f"加载插件 {current_id} 出错:{error} - {traceback.format_exc()}"
)
if plugin_id and plugin_id not in results:
if plugin_id and not any(
result_id.casefold() == plugin_id.casefold()
for result_id in results
):
status = PluginRuntimeStatus.LOAD_FAILED
self._runtime_status_writer(plugin_id, status)
results[plugin_id] = status
@@ -286,9 +289,10 @@ class PluginLifecycle:
return False
if plugin_id:
self._classes.pop(plugin_id, None)
self._running.pop(plugin_id, None)
self._quiesced_hooks.pop(plugin_id, None)
runtime_id = self._resolve_runtime_id(plugin_id)
self._classes.pop(runtime_id, None)
self._running.pop(runtime_id, None)
self._quiesced_hooks.pop(runtime_id, None)
else:
self._classes.clear()
self._running.clear()
@@ -300,14 +304,22 @@ class PluginLifecycle:
"""返回本阶段处理的稳定实例快照,并保持旧停机日志语义。"""
if plugin_id:
self._logger.info(f"正在停止插件 {plugin_id}...")
plugin = self._running.get(plugin_id)
plugins = {plugin_id: plugin} if plugin else {}
runtime_id = self._resolve_runtime_id(plugin_id)
plugin = self._running.get(runtime_id)
plugins = {runtime_id: plugin} if plugin else {}
if not plugin:
self._logger.debug(f"插件 {plugin_id} 不存在或未加载")
return plugins
self._logger.info("正在停止所有插件...")
return dict(self._running)
def _resolve_runtime_id(self, plugin_id: str) -> str:
"""按不区分大小写的插件 ID 找到运行时注册表键。"""
for runtime_id in (*self._running, *self._classes):
if runtime_id.casefold() == plugin_id.casefold():
return runtime_id
return plugin_id
def _is_quiesced(self, plugin_id: str, plugin: Any) -> bool:
"""判断 handler 及当前实例声明的旧 ABI hooks 是否均已成功收敛。"""
required = {self._EVENT_HANDLERS_QUIESCED} | {
@@ -334,6 +346,11 @@ class PluginLifecycle:
"""重启指定插件并返回本次加载结果。"""
self._runtime_status_writer(plugin_id, PluginRuntimeStatus.READY)
self.stop(plugin_id)
status = self.start(plugin_id)[plugin_id]
results = self.start(plugin_id)
status = next(
status
for result_id, status in results.items()
if result_id.casefold() == plugin_id.casefold()
)
self._event_sender(reload_event, data={"plugin_id": plugin_id})
return status