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
+60 -1
View File
@@ -1,5 +1,8 @@
"""FastAPI 动态插件路由适配器。""" """FastAPI 动态插件路由适配器。"""
import asyncio
from concurrent.futures import Future, TimeoutError as FutureTimeoutError
from threading import Lock
from typing import Any, Callable, Optional from typing import Any, Callable, Optional
from fastapi import Depends, FastAPI from fastapi import Depends, FastAPI
@@ -9,6 +12,8 @@ from fastapi.routing import APIRoute
class FastAPIDynamicRouteRegistry: class FastAPIDynamicRouteRegistry:
"""在 FastAPI 上注册插件自由响应路由,并维护 OpenAPI 缓存。""" """在 FastAPI 上注册插件自由响应路由,并维护 OpenAPI 缓存。"""
_dispatch_admission_timeout = 5.0
def __init__( def __init__(
self, self,
app: FastAPI, app: FastAPI,
@@ -19,6 +24,7 @@ class FastAPIDynamicRouteRegistry:
prefix: str, prefix: str,
protected_routes: set[str], protected_routes: set[str],
log: Any, log: Any,
event_loop: Callable[[], asyncio.AbstractEventLoop | None] | None = None,
) -> None: ) -> None:
"""注入应用、插件投影、认证依赖和日志端口。""" """注入应用、插件投影、认证依赖和日志端口。"""
self._app = app self._app = app
@@ -29,9 +35,62 @@ class FastAPIDynamicRouteRegistry:
self._prefix = prefix self._prefix = prefix
self._protected_routes = protected_routes self._protected_routes = protected_routes
self._logger = log self._logger = log
self._event_loop = event_loop
def update(self, plugin_id: Optional[str], action: str) -> None: def update(self, plugin_id: Optional[str], action: str) -> None:
"""按插件生命周期新增或移除动态路由。""" """在主事件循环中按插件生命周期新增或移除动态路由。"""
if self._event_loop is None:
self._update(plugin_id, action)
return
target_loop = self._event_loop()
if (
target_loop is None
or not target_loop.is_running()
or target_loop.is_closed()
):
raise RuntimeError("主事件循环未运行,无法更新插件动态路由")
try:
current_loop = asyncio.get_running_loop()
except RuntimeError:
current_loop = None
if current_loop is target_loop:
self._update(plugin_id, action)
return
completed: Future[None] = Future()
dispatch_lock = Lock()
dispatch_started = False
dispatch_abandoned = False
def apply_update() -> None:
"""在目标 loop 的单个回调中完成路由表与 OpenAPI 投影切换。"""
nonlocal dispatch_started
with dispatch_lock:
if dispatch_abandoned:
return
dispatch_started = True
try:
self._update(plugin_id, action)
except BaseException as error:
completed.set_exception(error)
else:
completed.set_result(None)
target_loop.call_soon_threadsafe(apply_update)
try:
completed.result(timeout=self._dispatch_admission_timeout)
except FutureTimeoutError as error:
with dispatch_lock:
if not dispatch_started:
dispatch_abandoned = True
raise RuntimeError(
"主事件循环未及时接收插件动态路由更新"
) from error
# 回调一旦开始便不可撤销,等待确定终态以免失败回滚后发生迟到写入。
completed.result()
def _update(self, plugin_id: Optional[str], action: str) -> None:
"""执行不可中断的路由表与 OpenAPI 投影更新。"""
if action not in {"add", "remove"}: if action not in {"add", "remove"}:
raise ValueError("Action must be 'add' or 'remove'") raise ValueError("Action must be 'add' or 'remove'")
+2
View File
@@ -24,6 +24,7 @@ from app.adapters.web.security.access import (
verify_token, verify_token,
) )
from app.application.security.token import create_access_token, decode_access_token from app.application.security.token import create_access_token, decode_access_token
from app.runtime.config import global_vars
from app.runtime.settings import RuntimeSettingsCompat from app.runtime.settings import RuntimeSettingsCompat
settings = RuntimeSettingsCompat() settings = RuntimeSettingsCompat()
@@ -393,6 +394,7 @@ def create_app() -> FastAPI:
"/redoc", "/redoc",
}, },
log=logger, log=logger,
event_loop=lambda: global_vars.CURRENT_EVENT_LOOP,
)) ))
return _app return _app
+26 -1
View File
@@ -71,6 +71,7 @@ LegacyPluginImportPreparer = Callable[..., None]
PluginInstallReporter = Callable[..., None] PluginInstallReporter = Callable[..., None]
SiteAuthLevelProvider = Callable[[], int] SiteAuthLevelProvider = Callable[[], int]
PluginCatalogFactory = Callable[["PluginManager"], Any] PluginCatalogFactory = Callable[["PluginManager"], Any]
PluginRouteRefresher = Callable[[str], None]
def _ignore_legacy_diagnostics(**_kwargs) -> None: def _ignore_legacy_diagnostics(**_kwargs) -> None:
@@ -109,6 +110,11 @@ def _warn_if_plugin_enabled_gil(
) )
def _unavailable_plugin_route_refresher(_plugin_id: str) -> None:
"""在 HTTP 组合尚未装配时拒绝发布不完整的热重载投影。"""
raise RuntimeError("插件动态路由刷新器尚未由启动组合根装配")
_legacy_diagnostics_configurator: LegacyDiagnosticsConfigurator = ( _legacy_diagnostics_configurator: LegacyDiagnosticsConfigurator = (
_ignore_legacy_diagnostics _ignore_legacy_diagnostics
) )
@@ -119,6 +125,7 @@ _legacy_plugin_import_preparer: LegacyPluginImportPreparer = (
_plugin_install_reporter: PluginInstallReporter = _ignore_legacy_diagnostics _plugin_install_reporter: PluginInstallReporter = _ignore_legacy_diagnostics
_site_auth_level_provider: SiteAuthLevelProvider = _unavailable_site_auth_level _site_auth_level_provider: SiteAuthLevelProvider = _unavailable_site_auth_level
_plugin_catalog_factory: PluginCatalogFactory = _unavailable_plugin_catalog_factory _plugin_catalog_factory: PluginCatalogFactory = _unavailable_plugin_catalog_factory
_plugin_route_refresher: PluginRouteRefresher = _unavailable_plugin_route_refresher
def configure_plugin_legacy_import_services( def configure_plugin_legacy_import_services(
@@ -158,6 +165,12 @@ def configure_plugin_catalog_factory(factory: PluginCatalogFactory) -> None:
_plugin_catalog_factory = factory _plugin_catalog_factory = factory
def configure_plugin_route_refresher(refresher: PluginRouteRefresher) -> None:
"""由启动组合根注入热重载后的动态路由投影刷新器。"""
global _plugin_route_refresher
_plugin_route_refresher = refresher
@observe_compat_facade("PluginManager") @observe_compat_facade("PluginManager")
class PluginManager(ConfigReloadMixin, metaclass=Singleton): class PluginManager(ConfigReloadMixin, metaclass=Singleton):
"""插件管理器""" """插件管理器"""
@@ -656,7 +669,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
monitor_suppressed=self.is_plugin_monitor_suppressed, monitor_suppressed=self.is_plugin_monitor_suppressed,
local_candidate=self._get_local_plugin_candidate_from_path, local_candidate=self._get_local_plugin_candidate_from_path,
sync_local=self._sync_local_plugin_if_installed, sync_local=self._sync_local_plugin_if_installed,
reload_plugin=self.reload_plugin_tree, reload_plugin=self._reload_plugin_tree_from_monitor,
dependency_manifest_status=( dependency_manifest_status=(
get_plugin_system().dependency_manifest_status get_plugin_system().dependency_manifest_status
), ),
@@ -664,6 +677,18 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
log=logger, log=logger,
).run() ).run()
def _reload_plugin_tree_from_monitor(
self,
plugin_id: str,
) -> PluginRuntimeStatus:
"""重载源码树,并发布源插件及虚拟实例的动态路由投影。"""
with self.mutation("热重载插件路由"):
status = self.reload_plugin_tree(plugin_id)
reload_targets = self.get_plugin_reload_targets(plugin_id)
for reload_target in reload_targets:
_plugin_route_refresher(reload_target)
return status
def _get_federated_plugin_change( def _get_federated_plugin_change(
self, self,
event_path: Path, event_path: Path,
+9
View File
@@ -32,6 +32,7 @@ from app.application.agentdata import get_agent_task_port
from app.application.database import get_database_governance from app.application.database import get_database_governance
from app.application.outbox import dispatch_pending_outbox from app.application.outbox import dispatch_pending_outbox
from app.application.plugin.runtime import get_plugin_manager from app.application.plugin.runtime import get_plugin_manager
from app.application.plugin.routes import register_plugin_api
from app.application.configuration import ( from app.application.configuration import (
SchedulerRuntimeConfig, SchedulerRuntimeConfig,
get_configured_system_config, get_configured_system_config,
@@ -152,6 +153,8 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
self._auth_count = 0 self._auth_count = 0
# 用户认证失败消息发送 # 用户认证失败消息发送
self._auth_message = False self._auth_message = False
# 插件已按认证结果重建,但动态路由尚未完成投影时保留重试状态。
self._auth_plugin_routes_pending = False
async def on_config_changed(self) -> None: async def on_config_changed(self) -> None:
""" """
@@ -2054,6 +2057,9 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
""" """
config = get_scheduler_runtime_config() config = get_scheduler_runtime_config()
if SitesHelper().auth_level >= 2: if SitesHelper().auth_level >= 2:
if self._auth_plugin_routes_pending:
register_plugin_api()
self._auth_plugin_routes_pending = False
return return
# 最大重试次数 # 最大重试次数
__max_try__ = 30 __max_try__ = 30
@@ -2088,6 +2094,9 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
# 认证通过后重新初始化插件 # 认证通过后重新初始化插件
get_plugin_manager().init_config() get_plugin_manager().init_config()
self.init_plugin_jobs() self.init_plugin_jobs()
self._auth_plugin_routes_pending = True
register_plugin_api()
self._auth_plugin_routes_pending = False
else: else:
self._auth_count += 1 self._auth_count += 1
+2
View File
@@ -15,6 +15,7 @@ from app.runtime.extensions.plugin_manager import (
configure_plugin_catalog_factory, configure_plugin_catalog_factory,
configure_plugin_install_reporter, configure_plugin_install_reporter,
configure_plugin_legacy_import_services, configure_plugin_legacy_import_services,
configure_plugin_route_refresher,
configure_plugin_resource_import_preparer, configure_plugin_resource_import_preparer,
configure_site_auth_level_provider, configure_site_auth_level_provider,
) )
@@ -95,6 +96,7 @@ def configure_plugin_services() -> None:
lambda: get_configured_system_config().get(SystemConfigKey.UserInstalledPlugins) or [] lambda: get_configured_system_config().get(SystemConfigKey.UserInstalledPlugins) or []
) )
configure_plugin_catalog_factory(_build_plugin_catalog) configure_plugin_catalog_factory(_build_plugin_catalog)
configure_plugin_route_refresher(register_plugin_api)
configure_plugin_system(PluginSystemServices( configure_plugin_system(PluginSystemServices(
market=market_client, market=market_client,
package=PluginPackageManager(plugin_helper), package=PluginPackageManager(plugin_helper),
+4 -2
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [], "runtime_to_db": [],
"workflow_to_db": [] "workflow_to_db": []
}, },
"edge_count": 6599, "edge_count": 6601,
"edge_sha256": "33b44ea7d1f05a4c76fd22c4c1b798689ac8e580099c50141fbd91345a625714", "edge_sha256": "4ebc1db325d75ac04418e89c199dc2b09eb95905e6e03a4101b7780d6b184c7c",
"edges": [ "edges": [
"app -> app.runtime", "app -> app.runtime",
"app -> app.runtime.compat", "app -> app.runtime.compat",
@@ -3937,6 +3937,7 @@
"app.factory -> app.application.security", "app.factory -> app.application.security",
"app.factory -> app.application.security.token", "app.factory -> app.application.security.token",
"app.factory -> app.runtime", "app.factory -> app.runtime",
"app.factory -> app.runtime.config",
"app.factory -> app.runtime.correlation", "app.factory -> app.runtime.correlation",
"app.factory -> app.runtime.localization", "app.factory -> app.runtime.localization",
"app.factory -> app.runtime.log", "app.factory -> app.runtime.log",
@@ -5857,6 +5858,7 @@
"app.scheduler -> app.application.messaging.message", "app.scheduler -> app.application.messaging.message",
"app.scheduler -> app.application.outbox", "app.scheduler -> app.application.outbox",
"app.scheduler -> app.application.plugin", "app.scheduler -> app.application.plugin",
"app.scheduler -> app.application.plugin.routes",
"app.scheduler -> app.application.plugin.runtime", "app.scheduler -> app.application.plugin.runtime",
"app.scheduler -> app.application.scheduling", "app.scheduler -> app.application.scheduling",
"app.scheduler -> app.application.site", "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 types import SimpleNamespace
from typing import Any from typing import Any
from unittest.mock import MagicMock
import httpx import httpx
import pytest import pytest
@@ -928,6 +933,144 @@ async def test_plugin_routes_ignore_included_router_wrappers():
assert removed_response.status_code == 404 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(): def test_response_router_uses_response_route_class():
"""统一路由器应默认创建统一响应路由。""" """统一路由器应默认创建统一响应路由。"""
router = ResponseAPIRouter() 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.admission import PluginMutationAdmission
from app.runtime.extensions.plugin.system import reset_plugin_system 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.runtime.extensions.plugin_manager import PluginManager
from app.schemas.plugin import PluginRuntimeStatus from app.schemas.plugin import PluginRuntimeStatus
from app.startup.initializers import plugins as plugins_initializer 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() 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: def test_plugin_monitor_suppression_is_reference_counted(monkeypatch) -> None:
"""同一插件的重叠写入必须等最后一个事务退出后才解除监控抑制。""" """同一插件的重叠写入必须等最后一个事务退出后才解除监控抑制。"""
_reset_plugin_manager() _reset_plugin_manager()
+89
View File
@@ -2,6 +2,8 @@ import asyncio
import threading import threading
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock, Mock
import pytest
from app import scheduler as scheduler_module from app import scheduler as scheduler_module
from app.scheduler import Scheduler from app.scheduler import Scheduler
from app.application.configuration import SchedulerRuntimeConfig 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 "clear_cache" in scheduler._jobs
assert scheduler._jobs["clear_cache"]["manual"] is True assert scheduler._jobs["clear_cache"]["manual"] is True
assert background_scheduler.started 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()