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
+3 -2
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"edge_count": 6807,
"edge_sha256": "93c39ca5828e23fcb5e4b33ffea101cb1ebc9a4ef6fac1317e2ebd482592ecdd",
"edge_count": 6808,
"edge_sha256": "bde5dc465241463f8695da3a7140dfed1f05757fd3f57493fa91d20fd80b6847",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -2788,6 +2788,7 @@
"app.application.plugin.install -> app.runtime.log",
"app.application.plugin.install -> app.schemas",
"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.plugin",
"app.application.plugin.inventory -> app.application.plugin.identity",
+39
View File
@@ -1,5 +1,7 @@
"""插件市场候选库存读取测试。"""
import asyncio
import pytest
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] == [
"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):
self.status_code = status_code
self._payload = payload or {}
self.reason = self._payload.get("message", "")
self.text = self.reason
def json(self):
"""返回构造时注入的 JSON payload。"""
@@ -2160,7 +2162,7 @@ demo = { index = "private" }
def test_install_reports_filelist_error_after_release_fallback_fails(self, monkeypatch):
"""
release 和文件列表都不可用时返回最终文件列表错误,并在每次写入前后保持目录可回滚。
release 和源码目录都不存在时返回稳定业务错误,并在每次写入前后保持目录可回滚。
"""
try:
from app.adapters.external.market import PluginHelper
@@ -2172,14 +2174,14 @@ demo = { index = "private" }
helper,
monkeypatch,
{"release": True, "version": "1.2.3"},
(False, "未找到资产文件:demoplugin_v1.2.3.zip"),
(False, "获取文件列表失败"),
(False, "DemoPlugin_v1.2.3 插件发布包不存在"),
(False, "DemoPlugin 插件源码目录不存在"),
)
success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
assert not success
assert "获取文件列表失败" == message
assert "DemoPlugin 插件源码目录不存在" == message
assert ["remove", "release", "remove", "filelist", "remove"] == calls
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):
"""
异步安装路径在 release 与文件列表都失败时返回文件列表错误,并保持失败清理顺序稳定。
异步安装路径在 release 与源码目录都不存在时返回稳定业务错误,并保持失败清理顺序稳定。
"""
try:
from app.adapters.external.market import PluginHelper
@@ -2614,8 +2616,8 @@ demo = { index = "private" }
helper,
monkeypatch,
{"release": True, "version": "1.2.3"},
(False, "未找到资产文件:demoplugin_v1.2.3.zip"),
(False, "获取文件列表失败"),
(False, "DemoPlugin_v1.2.3 插件发布包不存在"),
(False, "DemoPlugin 插件源码目录不存在"),
)
success, message = asyncio.run(
@@ -2623,12 +2625,12 @@ demo = { index = "private" }
)
assert not success
assert "获取文件列表失败" == message
assert "DemoPlugin 插件源码目录不存在" == message
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:
from app.adapters.external.market import PluginHelper
@@ -2657,11 +2659,11 @@ demo = { index = "private" }
assert success
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:
from app.adapters.external.market import PluginHelper
@@ -2690,7 +2692,7 @@ demo = { index = "private" }
assert success
assert "" == message
assert ["demoplugin"] == filelist_pids
assert ["DemoPlugin"] == filelist_pids
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")
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):
"""
@@ -3157,6 +3182,36 @@ demo = { index = "private" }
assert not success
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):
"""
文件列表存在但文件下载失败时向上返回下载错误。
@@ -3231,6 +3286,38 @@ demo = { index = "private" }
assert not success
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):
"""
异步文件列表下载失败时向上返回下载错误。
@@ -3374,7 +3461,36 @@ demo = { index = "private" }
)
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):
"""
+70 -3
View File
@@ -38,6 +38,7 @@ from app.schemas.exception import (
PersistenceUnavailableError,
PluginMutationRejectedError,
)
from app.schemas.plugin import PluginRuntimeStatus
NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc)
REPO_URL = "https://github.com/jxxghp/MoviePilot-Plugins"
@@ -299,7 +300,8 @@ def _command(
plugin_ids_provider=lambda: plugin_ids or [],
packages=packages,
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(),
registration_refresher=registration_refresher or AsyncMock(),
mutation=mutation or (lambda _operation: nullcontext()),
@@ -361,6 +363,10 @@ async def test_success_commits_journal_before_report_and_cleans_package_snapshot
return action
async def target_reload(_plugin_id):
calls.append("target_reload")
return PluginRuntimeStatus.ACTIVE
async def installer(**_kwargs):
calls.append("package")
return True, "installed"
@@ -380,7 +386,7 @@ async def test_success_commits_journal_before_report_and_cleans_package_snapshot
payload_receipt=receipt,
package_stage_backup=mark("stage_backup"),
package_activate_backup=mark("activate_backup"),
target_reloader=mark("target_reload"),
target_reloader=target_reload,
registration_refresher=mark("registrations"),
package_finalize_backup=mark("finalize_backup"),
package_commit=mark("package_commit"),
@@ -414,6 +420,67 @@ async def test_success_commits_journal_before_report_and_cleans_package_snapshot
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.parametrize(
"phase",
@@ -726,7 +793,7 @@ async def test_existing_matching_payload_only_refreshes_runtime():
"""同一来源、代际和版本已提交时只刷新运行态,不重复写包或 journal。"""
checkpointer = AsyncMock()
installer = AsyncMock()
reloader = AsyncMock()
reloader = AsyncMock(return_value=PluginRuntimeStatus.ACTIVE)
refresher = AsyncMock()
reporter = AsyncMock(return_value=True)
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(
(
MarketRead.present(REPO_URL, (official_v3,), package_generation="v3"),
MarketRead.present(REPO_URL, (official_v2,), package_generation="v2"),
MarketRead.present(
third_party.repo_url,
(third_party,),
package_generation="v3",
),
MarketRead.present(REPO_URL, (official_v3,), package_generation="v3"),
MarketRead.present(REPO_URL, (official_v2,), package_generation="v2"),
),
(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
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():
"""类已发现但权限策略拒绝时进入 blocked_by_policy。"""
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,
)
helper.get_plugins.cache_clear()
await helper.async_get_plugins.cache_clear()
helper.get_plugin_index_result.cache_clear()
await helper.async_get_plugin_index_result.cache_clear()
sync_result = helper.get_plugins(repo_url, "v3")
# 同步与异步装饰器按设计共享缓存区;清除后再验证异步 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")
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(
("status_code", "content", "expected"),
[
+38
View File
@@ -5,6 +5,8 @@ from pathlib import Path
from unittest.mock import MagicMock
import pytest
from uvicorn import Config
from uvicorn.supervisors.watchfilesreload import FileFilter
from app import factory, main
from app.runtime.topology import UnsupportedProcessTopologyError
@@ -60,11 +62,47 @@ def test_development_reload_uses_import_string_factory(monkeypatch):
host=main.settings.HOST,
port=main.settings.PORT,
reload=True,
reload_excludes=[
str(main.settings.ROOT_PATH / "app" / "plugins"),
str(main.settings.CONFIG_PATH),
],
workers=1,
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):
"""安全模式多 worker 由 Uvicorn supervisor 创建独立 ASGI factory 实例。"""
monkeypatch.setattr(main.settings, "DEV", False)