Files
MoviePilot/tests/test_plugin_lifecycle_coordinator.py
T

169 lines
6.0 KiB
Python

"""插件生命周期协调器的启动 owner/token 契约测试。"""
import asyncio
import threading
from concurrent.futures import ThreadPoolExecutor
from contextlib import suppress
import pytest
from app.application.plugin.lifecycle import PluginLifecycleCoordinator
async def _assert_event_waits(event: asyncio.Event) -> None:
"""确认事件在短预算内仍未发生,避免测试依赖固定 sleep 时序。"""
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(asyncio.shield(event.wait()), timeout=0.03)
async def _cancel_task(task: asyncio.Task) -> None:
"""取消仍在等待生命周期资格的任务并消费其终态。"""
task.cancel()
with suppress(asyncio.CancelledError):
await task
@pytest.mark.asyncio
async def test_startup_scope_yields_opaque_token_for_matching_plugin_hold() -> None:
"""启动 owner 取得的 token 可让内部取得逐插件资格。"""
coordinator = PluginLifecycleCoordinator()
async with coordinator.hold_startup() as startup_token:
assert startup_token is not None
assert not isinstance(startup_token, (str, bytes, int, bool))
entered = asyncio.Event()
release = asyncio.Event()
async def hold_plugin() -> None:
async with coordinator.hold("DemoPlugin", startup_token):
entered.set()
await release.wait()
task = asyncio.create_task(hold_plugin())
await entered.wait()
assert coordinator._active_plugins == {"demoplugin"}
release.set()
await task
assert coordinator._active_plugins == set()
@pytest.mark.asyncio
async def test_external_and_duplicate_plugin_holds_remain_blocked() -> None:
"""启动内部的逐插件资格不向外部调用放行,且同插件仍保持互斥。"""
coordinator = PluginLifecycleCoordinator()
internal_release = asyncio.Event()
duplicate_release = asyncio.Event()
internal_entered = asyncio.Event()
duplicate_entered = asyncio.Event()
external_entered = asyncio.Event()
async with coordinator.hold_startup() as startup_token:
async def internal_hold() -> None:
async with coordinator.hold("DemoPlugin", startup_token):
internal_entered.set()
await internal_release.wait()
async def duplicate_hold() -> None:
async with coordinator.hold("demoplugin", startup_token):
duplicate_entered.set()
await duplicate_release.wait()
async def external_hold() -> None:
async with coordinator.hold("DemoPlugin"):
external_entered.set()
internal_task = asyncio.create_task(internal_hold())
await internal_entered.wait()
duplicate_task = asyncio.create_task(duplicate_hold())
external_task = asyncio.create_task(external_hold())
await _assert_event_waits(duplicate_entered)
await _assert_event_waits(external_entered)
internal_release.set()
await internal_task
await duplicate_entered.wait()
await _assert_event_waits(external_entered)
duplicate_release.set()
await duplicate_task
await external_entered.wait()
await external_task
@pytest.mark.asyncio
async def test_foreign_and_expired_tokens_cannot_bypass_current_startup_lease() -> None:
"""其他 coordinator 或旧 lease 的 token 不得绕过当前启动 owner。"""
first = PluginLifecycleCoordinator()
second = PluginLifecycleCoordinator()
async with first.hold_startup() as foreign_token:
async with second.hold_startup() as current_token:
assert foreign_token is not current_token
foreign_entered = asyncio.Event()
async def foreign_hold() -> None:
async with second.hold("DemoPlugin", foreign_token):
foreign_entered.set()
foreign_task = asyncio.create_task(foreign_hold())
await _assert_event_waits(foreign_entered)
await _cancel_task(foreign_task)
async with second.hold_startup() as expired_token:
pass
async with second.hold_startup() as current_token:
assert expired_token is not current_token
expired_entered = asyncio.Event()
async def expired_hold() -> None:
async with second.hold("DemoPlugin", expired_token):
expired_entered.set()
expired_task = asyncio.create_task(expired_hold())
await _assert_event_waits(expired_entered)
await _cancel_task(expired_task)
@pytest.mark.asyncio
async def test_startup_token_can_cross_threads_without_contextvar() -> None:
"""显式 token 可跨线程传递,资格判断不依赖隐式 ContextVar。"""
coordinator = PluginLifecycleCoordinator()
main_thread = threading.current_thread().name
def run_in_thread(startup_token: object) -> tuple[str, set[str]]:
async def hold_plugin() -> tuple[str, set[str]]:
async with coordinator.hold("DemoPlugin", startup_token):
return threading.current_thread().name, set(coordinator._active_plugins)
return asyncio.run(hold_plugin())
with ThreadPoolExecutor(max_workers=1, thread_name_prefix="plugin-startup") as executor:
async with coordinator.hold_startup() as startup_token:
result = await asyncio.wrap_future(
executor.submit(run_in_thread, startup_token)
)
assert result[0] != main_thread
assert result[1] == {"demoplugin"}
@pytest.mark.asyncio
async def test_hold_without_token_retains_startup_waiting_compatibility() -> None:
"""无参数调用继续遵守启动全局资格的等待语义。"""
coordinator = PluginLifecycleCoordinator()
async with coordinator.hold_startup():
entered = asyncio.Event()
async def external_hold() -> None:
async with coordinator.hold("DemoPlugin"):
entered.set()
task = asyncio.create_task(external_hold())
await _assert_event_waits(entered)
await _cancel_task(task)