Files
MoviePilot/tests/test_plugin_lifecycle_blocking.py

240 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
慢插件钩子的准入拒绝、停机预算和真实资源所有权回归测试。
"""
import asyncio
import threading
import time
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import TimeoutError as FutureTimeoutError
from contextlib import contextmanager
from types import SimpleNamespace
from typing import Optional
from unittest.mock import MagicMock
import pytest
from app.runtime.extensions.plugin.admission import PluginMutationAdmission
from app.runtime.extensions.plugin.lifecycle import PluginLifecycle
from app.runtime.extensions.plugin.manager import PluginManager
from app.schemas.exception import PluginMutationRejectedError
@contextmanager
def _blocked_plugin(
manager: Optional[PluginManager] = None, *, start: bool = True,
) -> Iterator[SimpleNamespace]:
"""
在线程中阻塞真实 close 钩子,退出时始终放行并回收线程。
"""
entered = threading.Event()
release = threading.Event()
database = MagicMock()
calls = []
class SlowPlugin:
"""
以可控事件模拟未返回的第三方资源关闭钩子。
"""
def close(self) -> None:
"""
等待测试释放;兜底预算防止旧实现阻塞整个测试进程。
"""
entered.set()
calls.append("close")
assert release.wait(timeout=3)
instance = SlowPlugin()
running = {"SlowPlugin": instance}
lifecycle = PluginLifecycle(
classes={"SlowPlugin": SlowPlugin}, running=running,
load_plugins=lambda *_args: [], loadable_plugins=lambda: [],
plugin_config=lambda _pid: {}, auth_checker=lambda _plugin: True,
clear_modules=MagicMock(), clear_tools=MagicMock(),
enable_events=MagicMock(), disable_events=MagicMock(),
runtime_status_writer=MagicMock(), runtime_compatible=lambda _pid: True,
database=lambda: database, log=MagicMock(), event_sender=MagicMock(),
)
if manager is not None:
manager._plugin_lifecycle = lifecycle
manager._running_plugins = running
with ThreadPoolExecutor(max_workers=2) as executor:
stop = manager.stop if manager is not None else lifecycle.stop
future = executor.submit(stop, "SlowPlugin") if start else None
try:
if start:
assert entered.wait(timeout=1)
yield SimpleNamespace(
lifecycle=lifecycle, executor=executor, future=future,
running=running, instance=instance, database=database,
entered=entered, release=release, calls=calls,
)
finally:
release.set()
if future is not None:
future.result(timeout=1)
def _manager() -> PluginManager:
"""
仅装配停机与准入所需字段,避免触碰单例和真实插件持久化服务。
"""
manager = object.__new__(PluginManager)
manager._plugin_quiesce_lock = threading.RLock()
manager._plugin_quiesce_future = None
manager._plugin_service_quiesce_future = None
manager._plugin_runtime_closed = False
manager._plugin_mutation_admission = PluginMutationAdmission(
activity=lambda: manager._plugin_lifecycle.activity(),
)
return manager
def test_slow_hook_rejects_other_lifecycle_without_waiting() -> None:
"""
A 的钩子未退出时,B 必须明确拒绝执行且不能等到 A 释放。
"""
with _blocked_plugin() as blocked:
contender = blocked.executor.submit(blocked.lifecycle.start, "OtherPlugin")
with pytest.raises(PluginMutationRejectedError, match="SlowPlugin.*close"):
# 旧实现会在全局锁内等到此 Future 超时,因而使本断言失败。
try:
contender.result(timeout=0.3)
except FutureTimeoutError:
pytest.fail("慢钩子使其他插件的生命周期操作一直等待全局锁")
assert blocked.running["SlowPlugin"] is blocked.instance
blocked.database.release.assert_not_called()
@pytest.mark.asyncio
async def test_shutdown_budget_includes_contended_lifecycle_and_keeps_loop_alive(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
停机预算包含准入等待,事件循环可继续调度且资源不能提前释放。
"""
manager = _manager()
with _blocked_plugin(manager) as blocked:
monkeypatch.setattr(
"app.runtime.extensions.plugin.manager.ThreadHelper",
lambda: SimpleNamespace(submit=blocked.executor.submit),
)
heartbeat = asyncio.Event()
asyncio.get_running_loop().call_later(0.01, heartbeat.set)
started = time.monotonic()
result = await manager.quiesce_plugins(timeout=0.05)
assert time.monotonic() - started < 0.5
assert result is False
assert heartbeat.is_set()
assert blocked.running["SlowPlugin"] is blocked.instance
assert manager.finalize_plugins() is False
blocked.database.release.assert_not_called()
assert blocked.future.done() is False
def test_busy_mutation_rejects_before_side_effects_and_preserves_owner() -> None:
"""
根事务在写入前快速拒绝,副作用、owner 和数据库均保持真实状态。
"""
manager = _manager()
changed = []
with _blocked_plugin(manager) as blocked:
with pytest.raises(PluginMutationRejectedError, match="SlowPlugin.*close"):
with manager.mutation("保存 OtherPlugin 配置"):
changed.append(True)
assert changed == []
assert manager._plugin_mutation_admission.active_count == 1
assert manager._plugin_mutation_admission.wait_until_idle(timeout=0.01) is False
activity = manager.get_plugin_lifecycle_activity()
assert activity.plugin_id == "SlowPlugin"
assert activity.hook == "close"
assert activity.elapsed_seconds >= activity.hook_elapsed_seconds > 0
assert blocked.future.done() is False
assert manager._plugin_mutation_admission.wait_until_idle(timeout=0) is True
assert manager.get_plugin_lifecycle_activity() is None
with manager.mutation("重试保存 OtherPlugin 配置"):
changed.append(True)
assert changed == [True]
@pytest.mark.asyncio
async def test_cancelled_shutdown_keeps_real_hook_owner_and_reuses_future(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
调用方取消或再次超时都不能重复执行钩子、释放实例或销毁数据库。
"""
manager = _manager()
with _blocked_plugin(manager, start=False) as blocked:
monkeypatch.setattr(
"app.runtime.extensions.plugin.manager.ThreadHelper",
lambda: SimpleNamespace(submit=blocked.executor.submit),
)
assert await manager.quiesce_plugins(timeout=1) is True
task = asyncio.create_task(manager.quiesce_plugin_services(timeout=1))
try:
assert await asyncio.to_thread(blocked.entered.wait, 1)
owner = manager._plugin_service_quiesce_future
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert owner is not None and not owner.done()
assert await manager.quiesce_plugin_services(timeout=0.01) is False
assert manager._plugin_service_quiesce_future is owner
assert blocked.calls == ["close"]
assert manager.finalize_plugins() is False
assert manager.reopen_plugins() is False
assert blocked.running["SlowPlugin"] is blocked.instance
blocked.database.release.assert_not_called()
activity = manager.get_plugin_lifecycle_activity()
assert activity.operation == "quiesce_services"
assert activity.plugin_id == "SlowPlugin"
assert activity.hook == "close"
blocked.release.set()
assert await asyncio.wrap_future(owner) is True
assert manager.finalize_plugins() is True
assert blocked.running == {}
blocked.database.release.assert_called_once_with("SlowPlugin")
finally:
blocked.release.set()
if not task.done():
await task
@pytest.mark.asyncio
async def test_runtime_summary_exposes_real_hook_and_does_not_report_ready(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
摘要在后台收敛标记为空闲时,仍能识别实际执行中的插件钩子。
"""
from app.api.endpoints import plugin as endpoint
from app.schemas.plugin import PluginRuntimeStatus
with _blocked_plugin() as blocked:
manager = SimpleNamespace(
get_plugin_runtime_statuses=lambda: {"SlowPlugin": PluginRuntimeStatus.READY},
get_plugin_restart_requirements=lambda: {},
get_plugin_gil_fallbacks=lambda: [],
get_plugin_runtime_generation=lambda: 3,
is_plugin_settling=lambda: False,
get_plugin_lifecycle_activity=blocked.lifecycle.activity,
)
monkeypatch.setattr(endpoint, "get_plugin_manager", lambda: manager)
monkeypatch.setattr(
endpoint, "get_configured_system_config",
lambda: SimpleNamespace(get=lambda _key: ["SlowPlugin"]),
)
summary = await endpoint.runtime_status(None)
assert summary.ready is False
assert summary.pending_count == 1
assert summary.active_operation.plugin_id == "SlowPlugin"
assert summary.active_operation.hook == "close"
assert summary.model_dump()["active_operation"]["elapsed_seconds"] >= 0
summary = await endpoint.runtime_status(None)
assert summary.ready is True
assert summary.active_operation is None