mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 20:17:13 +08:00
169 lines
6.0 KiB
Python
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)
|