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)) releases.extend(cls.__normalize_plugin_release_response(payload))
return len(payload) >= 100 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( def get_plugin_index_result(
self, self,
repo_url: str, repo_url: str,
@@ -884,7 +884,6 @@ class PluginHelper(metaclass=WeakSingleton):
raise RuntimeError("插件索引响应格式无效") raise RuntimeError("插件索引响应格式无效")
return payload return payload
@cached(maxsize=128, ttl=1800)
def get_plugins(self, repo_url: str, def get_plugins(self, repo_url: str,
package_version: Optional[str] = None) -> Optional[Dict[str, dict]]: package_version: Optional[str] = None) -> Optional[Dict[str, dict]]:
""" """
@@ -892,16 +891,13 @@ class PluginHelper(metaclass=WeakSingleton):
:param repo_url: Github仓库地址 :param repo_url: Github仓库地址
:param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本 :param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本
""" """
request = self._build_plugin_index_request(repo_url, package_version) try:
if request is None: payload = self.get_plugin_index_result(repo_url, package_version)
except (ValueError, RuntimeError):
return None return None
package_url, headers = request return payload if payload is not None else {}
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)
@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]]: def _get_plugin_repo_releases(self, repo_url: str) -> Optional[List[dict]]:
""" """
按仓库获取 GitHub Release 原始分页数据,供仓库内所有插件共享。 按仓库获取 GitHub Release 原始分页数据,供仓库内所有插件共享。
@@ -1137,12 +1133,12 @@ class PluginHelper(metaclass=WeakSingleton):
return True, msg return True, msg
logger.warning(f"{pid} Release 安装失败,回退文件列表安装:{msg}") logger.warning(f"{pid} Release 安装失败,回退文件列表安装:{msg}")
self.__remove_old_plugin(pid) 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) return self.__install_flow_sync(pid, force_install, prepare_release, repo_url)
# 未声明 release 打包的插件继续使用文件列表方式安装。 # 未声明 release 打包的插件继续使用文件列表方式安装。
def prepare_filelist() -> Tuple[bool, str]: 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) return self.__install_flow_sync(pid, force_install, prepare_filelist, repo_url)
@@ -1236,6 +1232,8 @@ class PluginHelper(metaclass=WeakSingleton):
timeout=30) timeout=30)
if res is None: if res is None:
return None, "连接仓库失败" return None, "连接仓库失败"
elif res.status_code == 404:
return None, "插件源码目录不存在"
elif res.status_code != 200: elif res.status_code != 200:
return None, f"连接仓库失败:{res.status_code} - " \ return None, f"连接仓库失败:{res.status_code} - " \
f"{'超出速率限制,请设置Github Token或稍后重试' if res.status_code == 403 else res.reason}" f"{'超出速率限制,请设置Github Token或稍后重试' if res.status_code == 403 else res.reason}"
@@ -2385,8 +2383,12 @@ class PluginHelper(metaclass=WeakSingleton):
timeout=30, timeout=30,
is_api=True, is_api=True,
) )
if rel_res is None or rel_res.status_code != 200: if rel_res is None:
return False, f"获取 Release 信息失败:{rel_res.status_code if rel_res else '连接失败'}" 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: try:
rel_json = rel_res.json() rel_json = rel_res.json()
@@ -2550,7 +2552,7 @@ class PluginHelper(metaclass=WeakSingleton):
logger.error(f"[GitHub] 所有策略均请求失败,URL: {url},请检查网络连接或 GitHub 配置") logger.error(f"[GitHub] 所有策略均请求失败,URL: {url},请检查网络连接或 GitHub 配置")
return None 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( async def async_get_plugin_index_result(
self, self,
repo_url: str, repo_url: str,
@@ -2576,7 +2578,6 @@ class PluginHelper(metaclass=WeakSingleton):
raise RuntimeError("插件索引响应格式无效") raise RuntimeError("插件索引响应格式无效")
return payload return payload
@cached(maxsize=128, ttl=1800)
async def async_get_plugins(self, repo_url: str, async def async_get_plugins(self, repo_url: str,
package_version: Optional[str] = None) -> Optional[Dict[str, dict]]: package_version: Optional[str] = None) -> Optional[Dict[str, dict]]:
""" """
@@ -2584,19 +2585,16 @@ class PluginHelper(metaclass=WeakSingleton):
:param repo_url: Github仓库地址 :param repo_url: Github仓库地址
:param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本 :param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本
""" """
request = self._build_plugin_index_request(repo_url, package_version) try:
if request is None: payload = await self.async_get_plugin_index_result(
repo_url,
package_version,
)
except (ValueError, RuntimeError):
return None return None
package_url, headers = request return payload if payload is not None else {}
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)
@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]]: async def _async_get_plugin_repo_releases(self, repo_url: str) -> Optional[List[dict]]:
""" """
异步按仓库获取 GitHub Release 原始分页数据。 异步按仓库获取 GitHub Release 原始分页数据。
@@ -2725,6 +2723,8 @@ class PluginHelper(metaclass=WeakSingleton):
timeout=30) timeout=30)
if res is None: if res is None:
return None, "连接仓库失败" return None, "连接仓库失败"
elif res.status_code == 404:
return None, "插件源码目录不存在"
elif res.status_code != 200: elif res.status_code != 200:
return None, f"连接仓库失败:{res.status_code} - " \ return None, f"连接仓库失败:{res.status_code} - " \
f"{'超出速率限制,请设置Github Token或稍后重试' if res.status_code == 403 else res.text}" f"{'超出速率限制,请设置Github Token或稍后重试' if res.status_code == 403 else res.text}"
@@ -3287,12 +3287,12 @@ class PluginHelper(metaclass=WeakSingleton):
return True, msg return True, msg
logger.warning(f"{pid} Release 安装失败,回退文件列表安装:{msg}") logger.warning(f"{pid} Release 安装失败,回退文件列表安装:{msg}")
await self.__async_remove_old_plugin(pid) 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) return await self.__install_flow_async(pid, force_install, prepare_release, repo_url)
# 未声明 release 打包的插件继续使用文件列表方式安装。 # 未声明 release 打包的插件继续使用文件列表方式安装。
async def prepare_filelist() -> Tuple[bool, str]: 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) 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 not file_list:
if msg == "插件源码目录不存在":
return False, f"{pid} {msg}"
return False, 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: if not ok:
return False, m return False, m
return True, "" 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 not file_list:
if msg == "插件源码目录不存在":
return False, f"{pid} {msg}"
return False, 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: if not ok:
return False, m return False, m
return True, "" return True, ""
@@ -3399,8 +3414,12 @@ class PluginHelper(metaclass=WeakSingleton):
timeout=30, timeout=30,
is_api=True, is_api=True,
) )
if rel_res is None or rel_res.status_code != 200: if rel_res is None:
return False, f"获取 Release 信息失败:{rel_res.status_code if rel_res else '连接失败'}" 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: try:
rel_json = rel_res.json() rel_json = rel_res.json()
+12 -3
View File
@@ -23,11 +23,12 @@ from app.schemas.exception import (
PersistenceUnavailableError, PersistenceUnavailableError,
PluginMutationRejectedError, PluginMutationRejectedError,
) )
from app.schemas.plugin import PluginRuntimeStatus
InstalledPluginsReader = Callable[[], list[str]] InstalledPluginsReader = Callable[[], list[str]]
PluginIdsProvider = Callable[[], list[str]] PluginIdsProvider = Callable[[], list[str]]
InstallReporter = Callable[[str, str | None], Awaitable[object]] InstallReporter = Callable[[str, str | None], Awaitable[object]]
PluginReloader = Callable[[str], Awaitable[object]] PluginReloader = Callable[[str], Awaitable[PluginRuntimeStatus]]
PluginRegistrationRefresher = Callable[[str], Awaitable[object]] PluginRegistrationRefresher = Callable[[str], Awaitable[object]]
PluginMutationAdmission = Callable[[str], ContextManager[None]] PluginMutationAdmission = Callable[[str], ContextManager[None]]
PluginPackageWriteGuard = Callable[[str], ContextManager[None]] PluginPackageWriteGuard = Callable[[str], ContextManager[None]]
@@ -380,7 +381,7 @@ class PluginInstallCommand:
state.stage = "runtime_reload" state.stage = "runtime_reload"
state.runtime_touched = True 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.stage = "registration_refresh"
state.registrations_touched = True state.registrations_touched = True
await self.__await_side_effect( await self.__await_side_effect(
@@ -511,7 +512,7 @@ class PluginInstallCommand:
"""刷新已经提交且载荷事实未变化的插件运行态。""" """刷新已经提交且载荷事实未变化的插件运行态。"""
failure_stage = "runtime_reload" failure_stage = "runtime_reload"
try: try:
await self.__await_side_effect(self.__target_reloader(plugin_id)) await self.__reload_active(plugin_id)
failure_stage = "registration_refresh" failure_stage = "registration_refresh"
await self.__await_side_effect( await self.__await_side_effect(
self.__registration_refresher(plugin_id) self.__registration_refresher(plugin_id)
@@ -542,6 +543,14 @@ class PluginInstallCommand:
report_error=report_error, 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: async def __finish_committed(self, state: _InstallState) -> str:
"""幂等清理 COMMITTED 事务;失败时保留 journal 供启动回放。""" """幂等清理 COMMITTED 事务;失败时保留 journal 供启动回放。"""
try: try:
+1 -1
View File
@@ -53,7 +53,7 @@ class PluginCandidateInventoryReader:
local_candidate_loader: LocalCandidateLoader | None = None, local_candidate_loader: LocalCandidateLoader | None = None,
async_market_loader: AsyncMarketLoader | None = None, async_market_loader: AsyncMarketLoader | None = None,
generations: Sequence[str] = PLUGIN_V3_GENERATIONS, generations: Sequence[str] = PLUGIN_V3_GENERATIONS,
max_concurrency: int = 12, max_concurrency: int = 24,
) -> None: ) -> None:
"""保存读取端口,并限制异步市场请求的进程内并发。""" """保存读取端口,并限制异步市场请求的进程内并发。"""
normalized_generations = tuple( normalized_generations = tuple(
+5 -1
View File
@@ -689,7 +689,7 @@ def list_effective_online_candidates(
plugin_id: str, plugin_id: str,
generations: Sequence[str], generations: Sequence[str],
) -> tuple[PluginMarketCandidate, ...]: ) -> tuple[PluginMarketCandidate, ...]:
"""按来源列出当前运行代际实际可安装的最高版本候选。""" """按来源列出当前运行代际实际可安装的最高版本候选,官方来源始终置顶"""
generation_order = _normalize_generation_order(generations) generation_order = _normalize_generation_order(generations)
grouped: dict[ grouped: dict[
tuple[TrustedPluginSourceType, str], tuple[TrustedPluginSourceType, str],
@@ -706,6 +706,10 @@ def list_effective_online_candidates(
selected_candidate = _select_best(candidates, generation_order) selected_candidate = _select_best(candidates, generation_order)
if isinstance(selected_candidate, PluginMarketCandidate): if isinstance(selected_candidate, PluginMarketCandidate):
selected.append(selected_candidate) selected.append(selected_candidate)
selected.sort(
key=lambda candidate: candidate.source_type
is not TrustedPluginSourceType.OFFICIAL
)
return tuple(selected) return tuple(selected)
+7
View File
@@ -114,6 +114,13 @@ def run_api_server() -> None:
host=get_runtime_setting('HOST'), host=get_runtime_setting('HOST'),
port=get_runtime_setting('PORT'), port=get_runtime_setting('PORT'),
reload=get_runtime_setting('DEV'), 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'), workers=get_runtime_setting('API_WORKERS'),
timeout_graceful_shutdown=60, timeout_graceful_shutdown=60,
) )
+31 -14
View File
@@ -108,15 +108,15 @@ class PluginLifecycle:
plugins.sort(key=lambda item: getattr(item, "plugin_order", 0)) plugins.sort(key=lambda item: getattr(item, "plugin_order", 0))
for plugin in plugins: for plugin in plugins:
current_id = plugin.__name__ current_id = plugin.__name__
if plugin_id and current_id != plugin_id: if plugin_id and current_id.casefold() != plugin_id.casefold():
continue continue
try: try:
if not self._auth_checker(plugin): if not self._auth_checker(plugin):
if current_id in self._classes: if current_id in self._classes:
self._classes[current_id] = plugin self._classes[current_id] = plugin
status = PluginRuntimeStatus.BLOCKED_BY_POLICY status = PluginRuntimeStatus.BLOCKED_BY_POLICY
self._runtime_status_writer(current_id, status) self._runtime_status_writer(plugin_id or current_id, status)
results[current_id] = status results[plugin_id or current_id] = status
continue continue
self._classes[current_id] = plugin self._classes[current_id] = plugin
instance = plugin() instance = plugin()
@@ -131,16 +131,19 @@ class PluginLifecycle:
else: else:
self._disable_events(plugin) self._disable_events(plugin)
status = PluginRuntimeStatus.ACTIVE status = PluginRuntimeStatus.ACTIVE
self._runtime_status_writer(current_id, status) self._runtime_status_writer(plugin_id or current_id, status)
results[current_id] = status results[plugin_id or current_id] = status
except Exception as error: # noqa: BLE001 except Exception as error: # noqa: BLE001
status = PluginRuntimeStatus.LOAD_FAILED status = PluginRuntimeStatus.LOAD_FAILED
self._runtime_status_writer(current_id, status) self._runtime_status_writer(plugin_id or current_id, status)
results[current_id] = status results[plugin_id or current_id] = status
self._logger.error( self._logger.error(
f"加载插件 {current_id} 出错:{error} - {traceback.format_exc()}" 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 status = PluginRuntimeStatus.LOAD_FAILED
self._runtime_status_writer(plugin_id, status) self._runtime_status_writer(plugin_id, status)
results[plugin_id] = status results[plugin_id] = status
@@ -286,9 +289,10 @@ class PluginLifecycle:
return False return False
if plugin_id: if plugin_id:
self._classes.pop(plugin_id, None) runtime_id = self._resolve_runtime_id(plugin_id)
self._running.pop(plugin_id, None) self._classes.pop(runtime_id, None)
self._quiesced_hooks.pop(plugin_id, None) self._running.pop(runtime_id, None)
self._quiesced_hooks.pop(runtime_id, None)
else: else:
self._classes.clear() self._classes.clear()
self._running.clear() self._running.clear()
@@ -300,14 +304,22 @@ class PluginLifecycle:
"""返回本阶段处理的稳定实例快照,并保持旧停机日志语义。""" """返回本阶段处理的稳定实例快照,并保持旧停机日志语义。"""
if plugin_id: if plugin_id:
self._logger.info(f"正在停止插件 {plugin_id}...") self._logger.info(f"正在停止插件 {plugin_id}...")
plugin = self._running.get(plugin_id) runtime_id = self._resolve_runtime_id(plugin_id)
plugins = {plugin_id: plugin} if plugin else {} plugin = self._running.get(runtime_id)
plugins = {runtime_id: plugin} if plugin else {}
if not plugin: if not plugin:
self._logger.debug(f"插件 {plugin_id} 不存在或未加载") self._logger.debug(f"插件 {plugin_id} 不存在或未加载")
return plugins return plugins
self._logger.info("正在停止所有插件...") self._logger.info("正在停止所有插件...")
return dict(self._running) 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: def _is_quiesced(self, plugin_id: str, plugin: Any) -> bool:
"""判断 handler 及当前实例声明的旧 ABI hooks 是否均已成功收敛。""" """判断 handler 及当前实例声明的旧 ABI hooks 是否均已成功收敛。"""
required = {self._EVENT_HANDLERS_QUIESCED} | { required = {self._EVENT_HANDLERS_QUIESCED} | {
@@ -334,6 +346,11 @@ class PluginLifecycle:
"""重启指定插件并返回本次加载结果。""" """重启指定插件并返回本次加载结果。"""
self._runtime_status_writer(plugin_id, PluginRuntimeStatus.READY) self._runtime_status_writer(plugin_id, PluginRuntimeStatus.READY)
self.stop(plugin_id) 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}) self._event_sender(reload_event, data={"plugin_id": plugin_id})
return status return status
+3 -2
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [], "runtime_to_db": [],
"workflow_to_db": [] "workflow_to_db": []
}, },
"edge_count": 6807, "edge_count": 6808,
"edge_sha256": "93c39ca5828e23fcb5e4b33ffea101cb1ebc9a4ef6fac1317e2ebd482592ecdd", "edge_sha256": "bde5dc465241463f8695da3a7140dfed1f05757fd3f57493fa91d20fd80b6847",
"edges": [ "edges": [
"app -> app.runtime", "app -> app.runtime",
"app -> app.runtime.compat", "app -> app.runtime.compat",
@@ -2788,6 +2788,7 @@
"app.application.plugin.install -> app.runtime.log", "app.application.plugin.install -> app.runtime.log",
"app.application.plugin.install -> app.schemas", "app.application.plugin.install -> app.schemas",
"app.application.plugin.install -> app.schemas.exception", "app.application.plugin.install -> app.schemas.exception",
"app.application.plugin.install -> app.schemas.plugin",
"app.application.plugin.inventory -> app.application", "app.application.plugin.inventory -> app.application",
"app.application.plugin.inventory -> app.application.plugin", "app.application.plugin.inventory -> app.application.plugin",
"app.application.plugin.inventory -> app.application.plugin.identity", "app.application.plugin.inventory -> app.application.plugin.identity",
+39
View File
@@ -1,5 +1,7 @@
"""插件市场候选库存读取测试。""" """插件市场候选库存读取测试。"""
import asyncio
import pytest import pytest
from app.application.plugin.identity import TrustedPluginSourceType from app.application.plugin.identity import TrustedPluginSourceType
@@ -288,3 +290,40 @@ async def test_async_loader_preserves_generation_facts() -> None:
assert [read.package_generation for read in inventory.market_reads] == [ assert [read.package_generation for read in inventory.market_reads] == [
"v3", "v2", "v1" "v3", "v2", "v1"
] ]
@pytest.mark.asyncio
async def test_async_inventory_bounds_large_market_working_set() -> None:
"""大市场库存允许 24 个并发读取,但不能无界放大出站连接。"""
active = 0
max_active = 0
first_batch_started = asyncio.Event()
release = asyncio.Event()
async def loader(_market: str, _package_version: str | None, _force: bool):
nonlocal active, max_active
active += 1
max_active = max(max_active, active)
if active == 24:
first_batch_started.set()
await release.wait()
active -= 1
return {}
markets = [
f"https://github.com/concurrency-owner/repository-{index}"
for index in range(10)
]
reader = PluginCandidateInventoryReader(
market_loader=lambda *_args: {},
async_market_loader=loader,
)
inventory_task = asyncio.create_task(reader.async_load(markets))
await asyncio.wait_for(first_batch_started.wait(), timeout=1)
assert active == 24
release.set()
inventory = await inventory_task
assert max_active == 24
assert inventory.complete
+132 -16
View File
@@ -94,6 +94,8 @@ class _FakeResponse:
def __init__(self, status_code: int, payload: dict | None = None): def __init__(self, status_code: int, payload: dict | None = None):
self.status_code = status_code self.status_code = status_code
self._payload = payload or {} self._payload = payload or {}
self.reason = self._payload.get("message", "")
self.text = self.reason
def json(self): def json(self):
"""返回构造时注入的 JSON payload。""" """返回构造时注入的 JSON payload。"""
@@ -2160,7 +2162,7 @@ demo = { index = "private" }
def test_install_reports_filelist_error_after_release_fallback_fails(self, monkeypatch): def test_install_reports_filelist_error_after_release_fallback_fails(self, monkeypatch):
""" """
release 和文件列表都不可用时返回最终文件列表错误,并在每次写入前后保持目录可回滚。 release 和源码目录都不存在时返回稳定业务错误,并在每次写入前后保持目录可回滚。
""" """
try: try:
from app.adapters.external.market import PluginHelper from app.adapters.external.market import PluginHelper
@@ -2172,14 +2174,14 @@ demo = { index = "private" }
helper, helper,
monkeypatch, monkeypatch,
{"release": True, "version": "1.2.3"}, {"release": True, "version": "1.2.3"},
(False, "未找到资产文件:demoplugin_v1.2.3.zip"), (False, "DemoPlugin_v1.2.3 插件发布包不存在"),
(False, "获取文件列表失败"), (False, "DemoPlugin 插件源码目录不存在"),
) )
success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
assert not success assert not success
assert "获取文件列表失败" == message assert "DemoPlugin 插件源码目录不存在" == message
assert ["remove", "release", "remove", "filelist", "remove"] == calls assert ["remove", "release", "remove", "filelist", "remove"] == calls
def test_install_uses_filelist_when_release_flag_is_disabled(self, monkeypatch): def test_install_uses_filelist_when_release_flag_is_disabled(self, monkeypatch):
@@ -2602,7 +2604,7 @@ demo = { index = "private" }
def test_async_install_reports_filelist_error_after_release_fallback_fails(self, monkeypatch): def test_async_install_reports_filelist_error_after_release_fallback_fails(self, monkeypatch):
""" """
异步安装路径在 release 与文件列表都失败时返回文件列表错误,并保持失败清理顺序稳定。 异步安装路径在 release 与源码目录都不存在时返回稳定业务错误,并保持失败清理顺序稳定。
""" """
try: try:
from app.adapters.external.market import PluginHelper from app.adapters.external.market import PluginHelper
@@ -2614,8 +2616,8 @@ demo = { index = "private" }
helper, helper,
monkeypatch, monkeypatch,
{"release": True, "version": "1.2.3"}, {"release": True, "version": "1.2.3"},
(False, "未找到资产文件:demoplugin_v1.2.3.zip"), (False, "DemoPlugin_v1.2.3 插件发布包不存在"),
(False, "获取文件列表失败"), (False, "DemoPlugin 插件源码目录不存在"),
) )
success, message = asyncio.run( success, message = asyncio.run(
@@ -2623,12 +2625,12 @@ demo = { index = "private" }
) )
assert not success assert not success
assert "获取文件列表失败" == message assert "DemoPlugin 插件源码目录不存在" == message
assert calls == ["remove", "release", "remove", "filelist", "remove"] assert calls == ["remove", "release", "remove", "filelist", "remove"]
def test_async_install_release_fallback_uses_lowercase_filelist_pid(self, monkeypatch): def test_async_install_release_fallback_preserves_plugin_id(self, monkeypatch):
""" """
异步 release 回退文件列表安装时使用小写插件 ID,保持 GitHub 目录查询与同步路径一致 异步 release 回退保留清单中的插件 ID,供文件列表层生成可读错误
""" """
try: try:
from app.adapters.external.market import PluginHelper from app.adapters.external.market import PluginHelper
@@ -2657,11 +2659,11 @@ demo = { index = "private" }
assert success assert success
assert "" == message assert "" == message
assert ["demoplugin"] == filelist_pids assert ["DemoPlugin"] == filelist_pids
def test_async_install_non_release_uses_lowercase_filelist_pid(self, monkeypatch): def test_async_install_non_release_preserves_plugin_id(self, monkeypatch):
""" """
异步文件列表直装使用小写插件 ID,避免大小写插件 ID 影响远端目录匹配 异步文件列表直装保留清单中的插件 ID,路径规范化由文件列表层负责
""" """
try: try:
from app.adapters.external.market import PluginHelper from app.adapters.external.market import PluginHelper
@@ -2690,7 +2692,7 @@ demo = { index = "private" }
assert success assert success
assert "" == message assert "" == message
assert ["demoplugin"] == filelist_pids assert ["DemoPlugin"] == filelist_pids
def test_install_from_release_reports_missing_tag(self, monkeypatch): def test_install_from_release_reports_missing_tag(self, monkeypatch):
""" """
@@ -2707,7 +2709,30 @@ demo = { index = "private" }
success, message = helper._PluginHelper__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") success, message = helper._PluginHelper__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3")
assert not success assert not success
assert "获取 Release 信息失败:404" == message assert "DemoPlugin_v1.2.3 插件发布包不存在" == message
def test_get_file_list_reports_missing_plugin_directory(self, monkeypatch):
"""索引存在但源码目录缺失时不向用户暴露底层 HTTP 404。"""
try:
from app.adapters.external.market import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
monkeypatch.setattr(
helper,
"_PluginHelper__request_with_fallback",
lambda *_args, **_kwargs: _FakeResponse(404),
)
file_list, message = helper._PluginHelper__get_file_list(
PLUGIN_ID,
"demo/repo",
"v2",
)
assert file_list is None
assert message == "插件源码目录不存在"
def test_install_from_release_reports_missing_asset(self, monkeypatch): def test_install_from_release_reports_missing_asset(self, monkeypatch):
""" """
@@ -3157,6 +3182,36 @@ demo = { index = "private" }
assert not success assert not success
assert "list failed" == message assert "list failed" == message
def test_prepare_content_via_filelist_sync_names_missing_plugin(self, monkeypatch):
"""同步文件列表层规范化路径 ID,并在源码目录缺失时保留原插件 ID。"""
try:
from app.adapters.external.market import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
requested_ids = []
def fake_file_list(pid, *_args):
requested_ids.append(pid)
return None, "插件源码目录不存在"
monkeypatch.setattr(
helper,
"_PluginHelper__get_file_list",
fake_file_list,
)
success, message = helper._PluginHelper__prepare_content_via_filelist_sync(
PLUGIN_ID,
"demo/repo",
"v2",
)
assert not success
assert message == "DemoPlugin 插件源码目录不存在"
assert requested_ids == ["demoplugin"]
def test_prepare_content_via_filelist_sync_returns_download_error(self, monkeypatch): def test_prepare_content_via_filelist_sync_returns_download_error(self, monkeypatch):
""" """
文件列表存在但文件下载失败时向上返回下载错误。 文件列表存在但文件下载失败时向上返回下载错误。
@@ -3231,6 +3286,38 @@ demo = { index = "private" }
assert not success assert not success
assert "list failed" == message assert "list failed" == message
def test_async_prepare_content_via_filelist_names_missing_plugin(self, monkeypatch):
"""异步文件列表层规范化路径 ID,并在源码目录缺失时保留原插件 ID。"""
try:
from app.adapters.external.market import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
requested_ids = []
async def fake_file_list(pid, *_args):
requested_ids.append(pid)
return None, "插件源码目录不存在"
monkeypatch.setattr(
helper,
"_PluginHelper__async_get_file_list",
fake_file_list,
)
success, message = asyncio.run(
helper._PluginHelper__prepare_content_via_filelist_async(
PLUGIN_ID,
"demo/repo",
"v2",
)
)
assert not success
assert message == "DemoPlugin 插件源码目录不存在"
assert requested_ids == ["demoplugin"]
def test_async_prepare_content_via_filelist_returns_download_error(self, monkeypatch): def test_async_prepare_content_via_filelist_returns_download_error(self, monkeypatch):
""" """
异步文件列表下载失败时向上返回下载错误。 异步文件列表下载失败时向上返回下载错误。
@@ -3374,7 +3461,36 @@ demo = { index = "private" }
) )
assert not success assert not success
assert "获取 Release 信息失败:404" == message assert "DemoPlugin_v1.2.3 插件发布包不存在" == message
def test_async_get_file_list_reports_missing_plugin_directory(self, monkeypatch):
"""异步源码目录读取将 HTTP 404 收敛为稳定业务语义。"""
try:
from app.adapters.external.market import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
async def fake_request(*_args, **_kwargs):
return _FakeResponse(404)
monkeypatch.setattr(
helper,
"_PluginHelper__async_request_with_fallback",
fake_request,
)
file_list, message = asyncio.run(
helper._PluginHelper__async_get_file_list(
PLUGIN_ID,
"demo/repo",
"v2",
)
)
assert file_list is None
assert message == "插件源码目录不存在"
def test_async_install_from_release_reports_missing_asset_id(self, monkeypatch): def test_async_install_from_release_reports_missing_asset_id(self, monkeypatch):
""" """
+70 -3
View File
@@ -38,6 +38,7 @@ from app.schemas.exception import (
PersistenceUnavailableError, PersistenceUnavailableError,
PluginMutationRejectedError, PluginMutationRejectedError,
) )
from app.schemas.plugin import PluginRuntimeStatus
NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc) NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc)
REPO_URL = "https://github.com/jxxghp/MoviePilot-Plugins" REPO_URL = "https://github.com/jxxghp/MoviePilot-Plugins"
@@ -299,7 +300,8 @@ def _command(
plugin_ids_provider=lambda: plugin_ids or [], plugin_ids_provider=lambda: plugin_ids or [],
packages=packages, packages=packages,
install_reporter=reporter or default_reporter, install_reporter=reporter or default_reporter,
target_reloader=target_reloader or AsyncMock(), target_reloader=target_reloader
or AsyncMock(return_value=PluginRuntimeStatus.ACTIVE),
rollback_reloader=rollback_reloader or AsyncMock(), rollback_reloader=rollback_reloader or AsyncMock(),
registration_refresher=registration_refresher or AsyncMock(), registration_refresher=registration_refresher or AsyncMock(),
mutation=mutation or (lambda _operation: nullcontext()), mutation=mutation or (lambda _operation: nullcontext()),
@@ -361,6 +363,10 @@ async def test_success_commits_journal_before_report_and_cleans_package_snapshot
return action return action
async def target_reload(_plugin_id):
calls.append("target_reload")
return PluginRuntimeStatus.ACTIVE
async def installer(**_kwargs): async def installer(**_kwargs):
calls.append("package") calls.append("package")
return True, "installed" return True, "installed"
@@ -380,7 +386,7 @@ async def test_success_commits_journal_before_report_and_cleans_package_snapshot
payload_receipt=receipt, payload_receipt=receipt,
package_stage_backup=mark("stage_backup"), package_stage_backup=mark("stage_backup"),
package_activate_backup=mark("activate_backup"), package_activate_backup=mark("activate_backup"),
target_reloader=mark("target_reload"), target_reloader=target_reload,
registration_refresher=mark("registrations"), registration_refresher=mark("registrations"),
package_finalize_backup=mark("finalize_backup"), package_finalize_backup=mark("finalize_backup"),
package_commit=mark("package_commit"), package_commit=mark("package_commit"),
@@ -414,6 +420,67 @@ async def test_success_commits_journal_before_report_and_cleans_package_snapshot
assert persistence.records == {} assert persistence.records == {}
@pytest.mark.asyncio
async def test_non_active_runtime_status_compensates_before_database_commit():
"""运行态重载未激活时,安装必须在数据库提交前补偿并返回失败。"""
package_restore = AsyncMock()
package_cleanup = AsyncMock()
rollback_reloader = AsyncMock(return_value=PluginRuntimeStatus.ACTIVE)
registration_refresher = AsyncMock()
target_reloader = AsyncMock(return_value=PluginRuntimeStatus.LOAD_FAILED)
reporter = AsyncMock()
command, persistence, calls = _command(
package_restore=package_restore,
package_cleanup=package_cleanup,
target_reloader=target_reloader,
rollback_reloader=rollback_reloader,
registration_refresher=registration_refresher,
reporter=reporter,
)
result = await _execute(command)
assert result.success is False
assert result.failure_stage == "runtime_reload"
assert result.package_installed is True
assert result.installed_list_persisted is False
assert result.runtime_reloaded is False
assert result.registrations_refreshed is False
assert result.reported is False
package_restore.assert_awaited_once()
rollback_reloader.assert_awaited_once_with("DemoPlugin")
registration_refresher.assert_awaited_once_with("DemoPlugin")
reporter.assert_not_awaited()
assert "journal_commit" not in calls
assert persistence.records == {}
@pytest.mark.asyncio
async def test_existing_plugin_with_non_active_runtime_status_is_not_reported_successfully():
"""已有载荷刷新失败时不得伪装成运行态成功或发送安装上报。"""
target_reloader = AsyncMock(return_value=PluginRuntimeStatus.LOAD_FAILED)
reporter = AsyncMock()
command, persistence, _ = _command(
installed=["DemoPlugin"],
plugin_ids=["DemoPlugin"],
target_reloader=target_reloader,
reporter=reporter,
)
result = await _execute(command, admission=_admission(identity=_identity()))
assert result.success is False
assert result.refreshed_only is True
assert result.failure_stage == "runtime_reload"
assert result.runtime_reloaded is False
assert result.registrations_refreshed is False
assert result.reported is False
target_reloader.assert_awaited_once_with("DemoPlugin")
reporter.assert_not_awaited()
assert persistence.records == {}
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize( @pytest.mark.parametrize(
"phase", "phase",
@@ -726,7 +793,7 @@ async def test_existing_matching_payload_only_refreshes_runtime():
"""同一来源、代际和版本已提交时只刷新运行态,不重复写包或 journal。""" """同一来源、代际和版本已提交时只刷新运行态,不重复写包或 journal。"""
checkpointer = AsyncMock() checkpointer = AsyncMock()
installer = AsyncMock() installer = AsyncMock()
reloader = AsyncMock() reloader = AsyncMock(return_value=PluginRuntimeStatus.ACTIVE)
refresher = AsyncMock() refresher = AsyncMock()
reporter = AsyncMock(return_value=True) reporter = AsyncMock(return_value=True)
command, persistence, _ = _command( command, persistence, _ = _command(
+2 -2
View File
@@ -213,13 +213,13 @@ async def test_gateway_source_inspection_preserves_sources_and_hides_local_path(
) )
inventory = CandidateInventory( inventory = CandidateInventory(
( (
MarketRead.present(REPO_URL, (official_v3,), package_generation="v3"),
MarketRead.present(REPO_URL, (official_v2,), package_generation="v2"),
MarketRead.present( MarketRead.present(
third_party.repo_url, third_party.repo_url,
(third_party,), (third_party,),
package_generation="v3", package_generation="v3",
), ),
MarketRead.present(REPO_URL, (official_v3,), package_generation="v3"),
MarketRead.present(REPO_URL, (official_v2,), package_generation="v2"),
), ),
(local,), (local,),
local_read=LocalCandidateRead.present((local,)), local_read=LocalCandidateRead.present((local,)),
+14
View File
@@ -61,6 +61,20 @@ def test_lifecycle_records_active_result():
assert statuses["DemoPlugin"] is PluginRuntimeStatus.ACTIVE assert statuses["DemoPlugin"] is PluginRuntimeStatus.ACTIVE
def test_targeted_lifecycle_accepts_case_insensitive_directory_id():
"""市场目录 ID 通常为小写,不能因插件类名大小写不同而误报加载失败。"""
lifecycle, classes, running, statuses = _lifecycle(
plugins=[_plugin_class()],
)
result = lifecycle.start("demoplugin")
assert result == {"demoplugin": PluginRuntimeStatus.ACTIVE}
assert "DemoPlugin" in classes
assert "DemoPlugin" in running
assert statuses["demoplugin"] is PluginRuntimeStatus.ACTIVE
def test_lifecycle_records_policy_block_without_runtime_instance(): def test_lifecycle_records_policy_block_without_runtime_instance():
"""类已发现但权限策略拒绝时进入 blocked_by_policy。""" """类已发现但权限策略拒绝时进入 blocked_by_policy。"""
lifecycle, _classes, running, statuses = _lifecycle( lifecycle, _classes, running, statuses = _lifecycle(
+171 -3
View File
@@ -43,11 +43,11 @@ async def test_sync_and_async_plugin_indexes_share_request_and_result(
async_request, async_request,
) )
helper.get_plugins.cache_clear() helper.get_plugin_index_result.cache_clear()
await helper.async_get_plugins.cache_clear() await helper.async_get_plugin_index_result.cache_clear()
sync_result = helper.get_plugins(repo_url, "v3") sync_result = helper.get_plugins(repo_url, "v3")
# 同步与异步装饰器按设计共享缓存区;清除后再验证异步 I/O 入口本身。 # 同步与异步装饰器按设计共享缓存区;清除后再验证异步 I/O 入口本身。
await helper.async_get_plugins.cache_clear() await helper.async_get_plugin_index_result.cache_clear()
async_result = await helper.async_get_plugins(repo_url, "v3") async_result = await helper.async_get_plugins(repo_url, "v3")
assert sync_result == async_result == { assert sync_result == async_result == {
@@ -60,6 +60,174 @@ async def test_sync_and_async_plugin_indexes_share_request_and_result(
) )
@pytest.mark.asyncio
async def test_market_read_warms_source_inventory_cache(monkeypatch) -> None:
"""市场目录成功读取后,来源库存不得再次请求同一仓库代际。"""
helper = PluginHelper()
repo_url = "https://github.com/policy-owner/shared-index-cache"
requests = 0
async def request(_url: str, *, headers: dict):
nonlocal requests
requests += 1
return SimpleNamespace(
status_code=200,
text='{"DemoPlugin": {"version": "1.2.3"}}',
)
monkeypatch.setattr(
helper,
"_PluginHelper__async_request_with_fallback",
request,
)
await helper.async_get_plugin_index_result.cache_clear()
market_result = await helper.async_get_plugins(repo_url, "v3")
inventory_result = await helper.async_get_plugin_index_result(repo_url, "v3")
assert market_result == inventory_result
assert requests == 1
def test_sync_force_refresh_bypasses_index_cache(
monkeypatch,
) -> None:
"""同步强刷必须绕过唯一索引缓存。"""
helper = PluginHelper()
client = PluginMarketClient(helper)
repo_url = "https://github.com/policy-owner/sync-force-refresh"
version = "1.0.0"
requests = 0
def request(_url: str, *, headers: dict):
nonlocal requests
requests += 1
return SimpleNamespace(
status_code=200,
text=f'{{"DemoPlugin": {{"version": "{version}"}}}}',
)
monkeypatch.setattr(
helper,
"_PluginHelper__request_with_fallback",
request,
)
helper.get_plugin_index_result.cache_clear()
assert client.get_plugins(repo_url, "v3") == {
"DemoPlugin": {"version": "1.0.0"},
}
assert client.get_plugins(repo_url, "v3") == {
"DemoPlugin": {"version": "1.0.0"},
}
assert requests == 1
version = "2.0.0"
assert client.get_plugins(repo_url, "v3", force=True) == {
"DemoPlugin": {"version": "2.0.0"},
}
assert requests == 2
@pytest.mark.asyncio
async def test_async_force_refresh_bypasses_index_cache(
monkeypatch,
) -> None:
"""异步强刷必须绕过唯一索引缓存。"""
helper = PluginHelper()
client = PluginMarketClient(helper)
repo_url = "https://github.com/policy-owner/async-force-refresh"
version = "1.0.0"
requests = 0
async def request(_url: str, *, headers: dict):
nonlocal requests
requests += 1
return SimpleNamespace(
status_code=200,
text=f'{{"DemoPlugin": {{"version": "{version}"}}}}',
)
monkeypatch.setattr(
helper,
"_PluginHelper__async_request_with_fallback",
request,
)
await helper.async_get_plugin_index_result.cache_clear()
assert await client.async_get_plugins(repo_url, "v3") == {
"DemoPlugin": {"version": "1.0.0"},
}
assert await client.async_get_plugins(repo_url, "v3") == {
"DemoPlugin": {"version": "1.0.0"},
}
assert requests == 1
version = "2.0.0"
assert await client.async_get_plugins(repo_url, "v3", force=True) == {
"DemoPlugin": {"version": "2.0.0"},
}
assert requests == 2
@pytest.mark.asyncio
async def test_absent_plugin_generation_is_cached(monkeypatch) -> None:
"""明确不存在的代际是稳定事实,后续来源检查不得重复请求。"""
helper = PluginHelper()
repo_url = "https://github.com/policy-owner/absent-index-cache"
requests = 0
async def request(_url: str, *, headers: dict):
nonlocal requests
requests += 1
return SimpleNamespace(status_code=404, text="404: Not Found")
monkeypatch.setattr(
helper,
"_PluginHelper__async_request_with_fallback",
request,
)
await helper.async_get_plugin_index_result.cache_clear()
first = await helper.async_get_plugin_index_result(repo_url, "v2")
second = await helper.async_get_plugin_index_result(repo_url, "v2")
assert first is second is None
assert requests == 1
@pytest.mark.asyncio
async def test_index_cache_retains_multi_market_generation_working_set(
monkeypatch,
) -> None:
"""数十个市场的多代索引不能因缓存容量不足立即重复出站。"""
helper = PluginHelper()
requests = 0
async def request(_url: str, *, headers: dict):
nonlocal requests
requests += 1
return SimpleNamespace(status_code=200, text='{"DemoPlugin": {}}')
monkeypatch.setattr(
helper,
"_PluginHelper__async_request_with_fallback",
request,
)
await helper.async_get_plugin_index_result.cache_clear()
targets = [
(f"https://github.com/cache-owner/repository-{index}", generation)
for index in range(100)
for generation in ("v3", "v2", None)
]
for repo_url, generation in targets:
await helper.async_get_plugin_index_result(repo_url, generation)
await helper.async_get_plugin_index_result(*targets[0])
assert requests == len(targets)
@pytest.mark.parametrize( @pytest.mark.parametrize(
("status_code", "content", "expected"), ("status_code", "content", "expected"),
[ [
+38
View File
@@ -5,6 +5,8 @@ from pathlib import Path
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest import pytest
from uvicorn import Config
from uvicorn.supervisors.watchfilesreload import FileFilter
from app import factory, main from app import factory, main
from app.runtime.topology import UnsupportedProcessTopologyError from app.runtime.topology import UnsupportedProcessTopologyError
@@ -60,11 +62,47 @@ def test_development_reload_uses_import_string_factory(monkeypatch):
host=main.settings.HOST, host=main.settings.HOST,
port=main.settings.PORT, port=main.settings.PORT,
reload=True, reload=True,
reload_excludes=[
str(main.settings.ROOT_PATH / "app" / "plugins"),
str(main.settings.CONFIG_PATH),
],
workers=1, workers=1,
timeout_graceful_shutdown=60, timeout_graceful_shutdown=60,
) )
def test_development_reload_ignores_plugin_runtime_state(monkeypatch):
"""插件载荷与恢复快照由热加载管理,仅宿主源码变更触发进程重载。"""
root_path = main.settings.ROOT_PATH
plugin_path = root_path / "app" / "plugins"
config_path = main.settings.CONFIG_PATH
assert plugin_path.is_dir()
assert config_path.is_dir()
monkeypatch.setattr(main.settings, "DEV", True)
monkeypatch.setattr(main.settings, "API_WORKERS", 1)
uvicorn_run = MagicMock()
monkeypatch.setattr(main.uvicorn, "run", uvicorn_run)
main.run_api_server()
reload_excludes = uvicorn_run.call_args.kwargs["reload_excludes"]
reload_filter = FileFilter(
Config(main.APP_FACTORY, reload=True, reload_excludes=reload_excludes)
)
assert reload_filter(plugin_path / "autosignin" / "__init__.py") is False
assert reload_filter(
config_path
/ "plugins_backup"
/ ".autosignin.staging-transaction"
/ "__init__.py"
) is False
assert reload_filter(
config_path / "plugin_transactions" / "transaction" / "package" / "__init__.py"
) is False
assert reload_filter(root_path / "app" / "main.py") is True
def test_safe_mode_multi_worker_uses_import_string_factory(monkeypatch): def test_safe_mode_multi_worker_uses_import_string_factory(monkeypatch):
"""安全模式多 worker 由 Uvicorn supervisor 创建独立 ASGI factory 实例。""" """安全模式多 worker 由 Uvicorn supervisor 创建独立 ASGI factory 实例。"""
monkeypatch.setattr(main.settings, "DEV", False) monkeypatch.setattr(main.settings, "DEV", False)