fix(plugin): serialize dynamic route projection (#6441)

This commit is contained in:
InfinityPacer
2026-08-25 06:40:43 +08:00
committed by GitHub
parent 880a1064e2
commit adbeff95d0
9 changed files with 365 additions and 4 deletions
+4 -2
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"edge_count": 6599,
"edge_sha256": "33b44ea7d1f05a4c76fd22c4c1b798689ac8e580099c50141fbd91345a625714",
"edge_count": 6601,
"edge_sha256": "4ebc1db325d75ac04418e89c199dc2b09eb95905e6e03a4101b7780d6b184c7c",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -3937,6 +3937,7 @@
"app.factory -> app.application.security",
"app.factory -> app.application.security.token",
"app.factory -> app.runtime",
"app.factory -> app.runtime.config",
"app.factory -> app.runtime.correlation",
"app.factory -> app.runtime.localization",
"app.factory -> app.runtime.log",
@@ -5857,6 +5858,7 @@
"app.scheduler -> app.application.messaging.message",
"app.scheduler -> app.application.outbox",
"app.scheduler -> app.application.plugin",
"app.scheduler -> app.application.plugin.routes",
"app.scheduler -> app.application.plugin.runtime",
"app.scheduler -> app.application.scheduling",
"app.scheduler -> app.application.site",
+143
View File
@@ -1,5 +1,10 @@
import asyncio
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
import httpx
import pytest
@@ -928,6 +933,144 @@ async def test_plugin_routes_ignore_included_router_wrappers():
assert removed_response.status_code == 404
async def test_plugin_route_updates_run_on_application_event_loop() -> None:
"""线程侧插件变更必须回投主 loop 后再修改 FastAPI 路由表。"""
app = FastAPI()
main_thread = threading.get_ident()
mutation_threads: list[int] = []
original_add_api_route = app.router.add_api_route
original_setup = app.setup
def record_add_api_route(*args, **kwargs):
mutation_threads.append(threading.get_ident())
return original_add_api_route(*args, **kwargs)
def record_setup() -> None:
mutation_threads.append(threading.get_ident())
original_setup()
app.router.add_api_route = record_add_api_route
app.setup = record_setup
loop = asyncio.get_running_loop()
registry = FastAPIDynamicRouteRegistry(
app=app,
plugin_ids=lambda: ["DemoPlugin"],
plugin_apis=lambda _plugin_id: [{
"path": "/DemoPlugin/health",
"endpoint": lambda: {"ok": True},
"methods": ["GET"],
"allow_anonymous": True,
}],
verify_token=lambda: None,
verify_apikey=lambda: None,
prefix="/api/v1/plugin",
protected_routes=set(),
log=SimpleNamespace(debug=lambda *_args: None, error=lambda *_args: None),
event_loop=lambda: loop,
)
await asyncio.to_thread(registry.update, "DemoPlugin", "add")
assert mutation_threads
assert set(mutation_threads) == {main_thread}
assert any(
getattr(route, "path", None) == "/api/v1/plugin/DemoPlugin/health"
for route in app.routes
)
def test_plugin_route_update_rejects_stopped_application_loop() -> None:
"""生产 loop 已释放时不得退回调用线程修改路由。"""
registry = FastAPIDynamicRouteRegistry(
app=FastAPI(),
plugin_ids=lambda: [],
plugin_apis=lambda _plugin_id: [],
verify_token=lambda: None,
verify_apikey=lambda: None,
prefix="/api/v1/plugin",
protected_routes=set(),
log=SimpleNamespace(debug=lambda *_args: None, error=lambda *_args: None),
event_loop=lambda: None,
)
with pytest.raises(RuntimeError, match="主事件循环未运行"):
registry.update("DemoPlugin", "remove")
async def test_plugin_route_update_abandons_late_application_loop_callback() -> None:
"""超时前未开始的回调可以迟到执行,但不得再写入路由或污染事件循环。"""
app = FastAPI()
loop = asyncio.get_running_loop()
plugin_apis = MagicMock(return_value=[])
loop_errors: list[dict] = []
previous_handler = loop.get_exception_handler()
loop.set_exception_handler(lambda _loop, context: loop_errors.append(context))
registry = FastAPIDynamicRouteRegistry(
app=app,
plugin_ids=lambda: [],
plugin_apis=plugin_apis,
verify_token=lambda: None,
verify_apikey=lambda: None,
prefix="/api/v1/plugin",
protected_routes=set(),
log=SimpleNamespace(debug=lambda *_args: None, error=lambda *_args: None),
event_loop=lambda: loop,
)
registry._dispatch_admission_timeout = 0.01
try:
with ThreadPoolExecutor(max_workers=1) as executor:
update = executor.submit(registry.update, "DemoPlugin", "add")
time.sleep(0.05)
with pytest.raises(RuntimeError, match="未及时接收"):
update.result()
await asyncio.sleep(0)
finally:
loop.set_exception_handler(previous_handler)
plugin_apis.assert_not_called()
assert not any(
getattr(route, "path", None) == "/api/v1/plugin/DemoPlugin/health"
for route in app.routes
)
assert loop_errors == []
async def test_started_plugin_route_update_waits_for_terminal_result() -> None:
"""回调开始后即使超过 admission 预算,也不得先报失败再迟到写入。"""
app = FastAPI()
loop = asyncio.get_running_loop()
def delayed_plugin_apis(_plugin_id: str) -> list[dict]:
time.sleep(0.05)
return [{
"path": "/DemoPlugin/health",
"endpoint": lambda: {"ok": True},
"methods": ["GET"],
"allow_anonymous": True,
}]
registry = FastAPIDynamicRouteRegistry(
app=app,
plugin_ids=lambda: ["DemoPlugin"],
plugin_apis=delayed_plugin_apis,
verify_token=lambda: None,
verify_apikey=lambda: None,
prefix="/api/v1/plugin",
protected_routes=set(),
log=SimpleNamespace(debug=lambda *_args: None, error=lambda *_args: None),
event_loop=lambda: loop,
)
registry._dispatch_admission_timeout = 0.01
await asyncio.to_thread(registry.update, "DemoPlugin", "add")
assert sum(
getattr(route, "path", None) == "/api/v1/plugin/DemoPlugin/health"
for route in app.routes
) == 1
def test_response_router_uses_response_route_class():
"""统一路由器应默认创建统一响应路由。"""
router = ResponseAPIRouter()
+30
View File
@@ -19,6 +19,7 @@ from app.runtime.extensions.plugin.monitor import (
)
from app.runtime.extensions.plugin.admission import PluginMutationAdmission
from app.runtime.extensions.plugin.system import reset_plugin_system
from app.runtime.extensions import plugin_manager as plugin_manager_module
from app.runtime.extensions.plugin_manager import PluginManager
from app.schemas.plugin import PluginRuntimeStatus
from app.startup.initializers import plugins as plugins_initializer
@@ -749,6 +750,35 @@ def test_plugin_monitor_skips_installing_plugin_until_package_write_finishes(tmp
reload_plugin.assert_not_called()
def test_monitor_reload_refreshes_source_and_instance_routes(monkeypatch) -> None:
"""源码热重载后必须刷新源插件及其虚拟实例的动态路由投影。"""
admission = PluginMutationAdmission()
manager = SimpleNamespace(
mutation=admission.hold,
get_plugin_reload_targets=MagicMock(
return_value=["DemoPlugin", "DemoPlugin_1"]
),
reload_plugin_tree=MagicMock(return_value=PluginRuntimeStatus.ACTIVE),
)
refresh_holds: list[bool] = []
refresh = MagicMock(
side_effect=lambda _plugin_id: refresh_holds.append(admission.is_held())
)
monkeypatch.setattr(plugin_manager_module, "_plugin_route_refresher", refresh)
status = PluginManager._reload_plugin_tree_from_monitor(manager, "DemoPlugin")
assert status is PluginRuntimeStatus.ACTIVE
assert admission.active_count == 0
assert refresh_holds == [True, True]
manager.get_plugin_reload_targets.assert_called_once_with("DemoPlugin")
manager.reload_plugin_tree.assert_called_once_with("DemoPlugin")
assert [item.args for item in refresh.call_args_list] == [
("DemoPlugin",),
("DemoPlugin_1",),
]
def test_plugin_monitor_suppression_is_reference_counted(monkeypatch) -> None:
"""同一插件的重叠写入必须等最后一个事务退出后才解除监控抑制。"""
_reset_plugin_manager()
+89
View File
@@ -2,6 +2,8 @@ import asyncio
import threading
from unittest.mock import AsyncMock, Mock
import pytest
from app import scheduler as scheduler_module
from app.scheduler import Scheduler
from app.application.configuration import SchedulerRuntimeConfig
@@ -133,3 +135,90 @@ def test_clear_cache_is_manual_only(monkeypatch):
assert "clear_cache" in scheduler._jobs
assert scheduler._jobs["clear_cache"]["manual"] is True
assert background_scheduler.started is True
def test_user_auth_refreshes_plugin_routes_after_runtime_reinitialization(monkeypatch):
"""自动认证重建插件实例后必须同步刷新动态路由投影。"""
scheduler = object.__new__(Scheduler)
scheduler._auth_count = 1
scheduler._auth_message = False
scheduler._auth_plugin_routes_pending = False
plugin_manager = Mock()
plugin_jobs = Mock()
refresh_routes = Mock()
message_chain = Mock()
monkeypatch.setattr(
scheduler_module,
"get_scheduler_runtime_config",
lambda: Mock(site_link="https://example.invalid"),
)
monkeypatch.setattr(
scheduler_module,
"SitesHelper",
lambda: Mock(auth_level=0, check_user=Mock(return_value=(True, "demo"))),
)
monkeypatch.setattr(
scheduler_module,
"get_configured_system_config",
lambda: Mock(get=Mock(return_value=None)),
)
monkeypatch.setattr(scheduler_module, "SchedulerChain", lambda: message_chain)
monkeypatch.setattr(
scheduler_module,
"get_plugin_manager",
lambda: plugin_manager,
)
monkeypatch.setattr(scheduler, "init_plugin_jobs", plugin_jobs)
monkeypatch.setattr(scheduler_module, "register_plugin_api", refresh_routes)
scheduler.user_auth()
plugin_manager.init_config.assert_called_once_with()
plugin_jobs.assert_called_once_with()
refresh_routes.assert_called_once_with()
assert scheduler._auth_plugin_routes_pending is False
def test_user_auth_retries_pending_plugin_route_projection(monkeypatch):
"""认证已成功但路由投影失败时,后续认证任务只重试未完成的投影。"""
scheduler = object.__new__(Scheduler)
scheduler._auth_count = 1
scheduler._auth_message = False
scheduler._auth_plugin_routes_pending = False
plugin_manager = Mock()
plugin_jobs = Mock()
refresh_routes = Mock(side_effect=[RuntimeError("loop unavailable"), None])
message_chain = Mock()
sites = Mock(auth_level=0, check_user=Mock(return_value=(True, "demo")))
monkeypatch.setattr(
scheduler_module,
"get_scheduler_runtime_config",
lambda: Mock(site_link="https://example.invalid"),
)
monkeypatch.setattr(scheduler_module, "SitesHelper", lambda: sites)
monkeypatch.setattr(
scheduler_module,
"get_configured_system_config",
lambda: Mock(get=Mock(return_value=None)),
)
monkeypatch.setattr(scheduler_module, "SchedulerChain", lambda: message_chain)
monkeypatch.setattr(
scheduler_module,
"get_plugin_manager",
lambda: plugin_manager,
)
monkeypatch.setattr(scheduler, "init_plugin_jobs", plugin_jobs)
monkeypatch.setattr(scheduler_module, "register_plugin_api", refresh_routes)
with pytest.raises(RuntimeError, match="loop unavailable"):
scheduler.user_auth()
assert scheduler._auth_plugin_routes_pending is True
sites.auth_level = 2
scheduler.user_auth()
assert scheduler._auth_plugin_routes_pending is False
assert refresh_routes.call_count == 2
plugin_manager.init_config.assert_called_once_with()
plugin_jobs.assert_called_once_with()
sites.check_user.assert_called_once_with()