Files
MoviePilot/tests/test_runtime_execution.py
T

197 lines
6.2 KiB
Python

"""运行时同步 worker 的取消与容量合同回归。"""
import asyncio
import ast
import json
import subprocess
import sys
import threading
from pathlib import Path
import pytest
from anyio.to_thread import current_default_thread_limiter
from app.adapters.external import market as market_adapter
from app.adapters.system.plugin import package as plugin_package_adapter
from app.runtime.execution import (
await_task_to_terminal,
run_in_threadpool_to_completion,
)
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def test_host_uses_canonical_threadpool_boundary() -> None:
"""canonical 宿主不得重新直连框架线程池 helper。"""
violations: list[str] = []
for path in sorted((PROJECT_ROOT / "app").rglob("*.py")):
relative_path = path.relative_to(PROJECT_ROOT).as_posix()
if relative_path.startswith(
("app/plugins/", "app/runtime/compat/", "app/sdk/", "app/testing/")
):
continue
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
for node in ast.walk(tree):
if not isinstance(node, ast.ImportFrom) or node.module not in {
"fastapi.concurrency",
"starlette.concurrency",
}:
continue
if any(alias.name == "run_in_threadpool" for alias in node.names):
violations.append(f"{relative_path}:{node.lineno}")
assert violations == []
@pytest.mark.parametrize("inherit_context", [0, 1])
def test_submit_with_context_is_independent_of_thread_inheritance(
inherit_context: int,
) -> None:
"""线程继承开关不得改变逐任务快照,worker 也不得保留首个请求状态。"""
script = """
import json
from concurrent.futures import ThreadPoolExecutor
from contextvars import ContextVar
from app.runtime.execution import submit_with_context
request_id = ContextVar("request_id", default=None)
executor = ThreadPoolExecutor(max_workers=1)
observed = []
for value in ("first", "second"):
token = request_id.set(value)
try:
observed.append(submit_with_context(executor, request_id.get).result())
finally:
request_id.reset(token)
observed.append(executor.submit(request_id.get).result())
executor.shutdown()
print(json.dumps(observed))
"""
completed = subprocess.run(
[sys.executable, "-X", f"thread_inherit_context={inherit_context}", "-c", script],
check=True,
capture_output=True,
text=True,
)
assert json.loads(completed.stdout) == ["first", "second", None]
def test_plugin_file_adapters_share_runtime_completion_contract() -> None:
"""市场与插件包适配器不得各自维护另一套线程取消实现。"""
assert market_adapter._await_thread_operation is run_in_threadpool_to_completion
assert (
plugin_package_adapter._await_thread_operation
is run_in_threadpool_to_completion
)
@pytest.mark.asyncio
async def test_await_task_to_terminal_ignores_repeated_cancellation() -> None:
"""调用方连续取消时,受保护任务仍须结束并返回真实结果。"""
started = asyncio.Event()
release = asyncio.Event()
async def protected_operation() -> str:
"""阻塞到测试释放,用于观察受保护任务的真实终态。"""
started.set()
await release.wait()
return "completed"
protected_task = asyncio.create_task(protected_operation())
waiter = asyncio.create_task(await_task_to_terminal(protected_task))
await started.wait()
waiter.cancel()
await asyncio.sleep(0)
waiter.cancel()
await asyncio.sleep(0)
assert waiter.done() is False
release.set()
assert await waiter == "completed"
@pytest.mark.asyncio
async def test_threadpool_capacity_is_held_until_cancelled_call_finishes() -> None:
"""调用方取消后,执行令牌必须由真实同步调用持有到终态。"""
limiter = current_default_thread_limiter()
original_capacity = limiter.total_tokens
release = threading.Event()
first_started = threading.Event()
second_started = threading.Event()
def blocking_call(started: threading.Event) -> None:
started.set()
release.wait()
limiter.total_tokens = 1
first = asyncio.create_task(
run_in_threadpool_to_completion(blocking_call, first_started)
)
second = None
try:
while not first_started.is_set():
await asyncio.sleep(0)
first.cancel()
await asyncio.sleep(0)
first.cancel()
await asyncio.sleep(0)
assert first.done() is False
assert limiter.borrowed_tokens == 1
second = asyncio.create_task(
run_in_threadpool_to_completion(blocking_call, second_started)
)
await asyncio.sleep(0.01)
assert second_started.is_set() is False
release.set()
with pytest.raises(asyncio.CancelledError):
await first
await second
finally:
release.set()
if not first.done():
await asyncio.gather(first, return_exceptions=True)
if second is not None and not second.done():
await asyncio.gather(second, return_exceptions=True)
limiter.total_tokens = original_capacity
@pytest.mark.asyncio
async def test_cancelled_threadpool_call_preserves_worker_failure_as_cause() -> None:
"""调用方取消优先返回,线程终态异常仍保留为诊断原因。"""
loop = asyncio.get_running_loop()
previous_handler = loop.get_exception_handler()
loop_errors: list[dict] = []
release = threading.Event()
started = threading.Event()
def failing_call() -> None:
started.set()
release.wait()
raise ValueError("worker failed")
task = asyncio.create_task(run_in_threadpool_to_completion(failing_call))
while not started.is_set():
await asyncio.sleep(0)
task.cancel()
release.set()
loop.set_exception_handler(lambda _loop, context: loop_errors.append(context))
try:
with pytest.raises(asyncio.CancelledError) as error_info:
await task
await asyncio.sleep(0)
finally:
loop.set_exception_handler(previous_handler)
assert isinstance(error_info.value.__cause__, ValueError)
assert loop_errors == []