mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 20:17:13 +08:00
448 lines
14 KiB
Python
448 lines
14 KiB
Python
"""进程内后台任务登记与关停语义测试。"""
|
|
|
|
import asyncio
|
|
import concurrent.futures
|
|
import inspect
|
|
import threading
|
|
import weakref
|
|
from collections.abc import Coroutine
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from app.runtime.tasks import TaskRegistry
|
|
|
|
|
|
class _CloseCountingCoroutine(Coroutine[Any, Any, None]):
|
|
"""记录 pending submission 的协程关闭所有权。"""
|
|
|
|
def __init__(self) -> None:
|
|
self.close_count = 0
|
|
|
|
def __await__(self):
|
|
return self
|
|
|
|
def send(self, _value):
|
|
raise StopIteration
|
|
|
|
def throw(self, typ, val=None, tb=None):
|
|
if val is None:
|
|
raise typ
|
|
raise val.with_traceback(tb)
|
|
|
|
def close(self) -> None:
|
|
self.close_count += 1
|
|
|
|
|
|
def test_task_registry_removes_completed_task() -> None:
|
|
"""正常完成的任务应自动退出登记表,避免长期持有请求对象。"""
|
|
|
|
async def scenario() -> None:
|
|
registry = TaskRegistry()
|
|
release = asyncio.Event()
|
|
|
|
async def worker() -> None:
|
|
"""等待测试释放信号。"""
|
|
await release.wait()
|
|
|
|
task = registry.create(worker(), owner="test.completed")
|
|
assert [record.owner for record in registry.records] == ["test.completed"]
|
|
|
|
release.set()
|
|
await task
|
|
await asyncio.sleep(0)
|
|
|
|
assert registry.records == ()
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_task_registry_cancels_tasks_and_rejects_late_registration() -> None:
|
|
"""关停应取消存量任务,并拒绝在资源释放阶段继续产生新任务。"""
|
|
|
|
async def scenario() -> None:
|
|
registry = TaskRegistry()
|
|
started = asyncio.Event()
|
|
cancelled = asyncio.Event()
|
|
|
|
async def worker() -> None:
|
|
"""记录任务收到取消信号。"""
|
|
started.set()
|
|
try:
|
|
await asyncio.Event().wait()
|
|
except asyncio.CancelledError:
|
|
cancelled.set()
|
|
raise
|
|
|
|
task = registry.create(worker(), owner="test.shutdown")
|
|
await started.wait()
|
|
assert await registry.shutdown(timeout_seconds=1.0) is True
|
|
|
|
assert task.cancelled()
|
|
assert cancelled.is_set()
|
|
assert registry.records == ()
|
|
|
|
async def late_worker() -> None:
|
|
"""模拟关停开始后到达的晚任务。"""
|
|
|
|
with pytest.raises(RuntimeError, match="正在关闭"):
|
|
registry.create(late_worker(), owner="test.late")
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_task_registry_runs_sync_function_and_tracks_until_completion() -> None:
|
|
"""同步任务应在线程池执行,并在真实完成前保留 owner 记录。"""
|
|
|
|
async def scenario() -> None:
|
|
registry = TaskRegistry()
|
|
release = asyncio.Event()
|
|
|
|
def worker(value: int) -> int:
|
|
"""返回传入值,验证参数和结果没有被登记器改写。"""
|
|
return value
|
|
|
|
task = registry.create_sync(worker, 7, owner="test.sync")
|
|
assert [record.owner for record in registry.records] == ["test.sync"]
|
|
assert await task == 7
|
|
await asyncio.sleep(0)
|
|
assert registry.records == ()
|
|
|
|
release.set()
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_task_registry_owns_threadsafe_submission_until_shutdown() -> None:
|
|
"""宿主线程提交的协程应先登记 owner,并由 Registry 关停取消和等待。"""
|
|
|
|
async def scenario() -> None:
|
|
registry = TaskRegistry()
|
|
loop = asyncio.get_running_loop()
|
|
started = asyncio.Event()
|
|
cleaned = asyncio.Event()
|
|
|
|
async def worker() -> None:
|
|
"""保持运行直到 Registry 发出取消,并记录清理已完成。"""
|
|
started.set()
|
|
try:
|
|
await asyncio.Event().wait()
|
|
finally:
|
|
cleaned.set()
|
|
|
|
completion = await asyncio.to_thread(
|
|
registry.submit_threadsafe,
|
|
worker(),
|
|
loop=loop,
|
|
owner="test.threadsafe",
|
|
)
|
|
await asyncio.wait_for(started.wait(), timeout=1)
|
|
assert [record.owner for record in registry.records] == [
|
|
"test.threadsafe"
|
|
]
|
|
|
|
assert await registry.shutdown(timeout_seconds=1.0) is True
|
|
assert cleaned.is_set()
|
|
assert completion.cancelled()
|
|
assert registry.records == ()
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_task_registry_rejects_threadsafe_submission_after_shutdown() -> None:
|
|
"""关停先赢得竞态时应关闭协程并同步拒绝提交。"""
|
|
|
|
async def scenario() -> None:
|
|
registry = TaskRegistry()
|
|
loop = asyncio.get_running_loop()
|
|
|
|
async def late_worker() -> None:
|
|
"""模拟关停完成后从宿主线程到达的晚任务。"""
|
|
|
|
assert await registry.shutdown(timeout_seconds=1.0) is True
|
|
coroutine = late_worker()
|
|
with pytest.raises(RuntimeError, match="正在关闭"):
|
|
await asyncio.to_thread(
|
|
registry.submit_threadsafe,
|
|
coroutine,
|
|
loop=loop,
|
|
owner="test.threadsafe-late",
|
|
)
|
|
|
|
assert inspect.getcoroutinestate(coroutine) == inspect.CORO_CLOSED
|
|
assert registry.records == ()
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_task_registry_shutdown_closes_submission_before_loop_dispatch() -> None:
|
|
"""回调尚未执行时关停也必须关闭协程并结束 completion。"""
|
|
registry = TaskRegistry()
|
|
loop = asyncio.new_event_loop()
|
|
|
|
async def worker() -> None:
|
|
"""不应被迟到的 loop callback 启动。"""
|
|
|
|
coroutine = worker()
|
|
completion = registry.submit_threadsafe(
|
|
coroutine,
|
|
loop=loop,
|
|
owner="test.threadsafe-pending",
|
|
)
|
|
|
|
assert asyncio.run(registry.shutdown(timeout_seconds=0.01)) is True
|
|
assert completion.cancelled()
|
|
assert inspect.getcoroutinestate(coroutine) == inspect.CORO_CLOSED
|
|
|
|
loop.run_until_complete(asyncio.sleep(0))
|
|
assert registry.records == ()
|
|
loop.close()
|
|
|
|
|
|
def test_task_registry_caller_cancels_before_loop_dispatch() -> None:
|
|
"""调用方在 callback 前取消时应关闭协程,迟到 callback 不得创建 Task。"""
|
|
registry = TaskRegistry()
|
|
loop = asyncio.new_event_loop()
|
|
|
|
async def worker() -> None:
|
|
"""不应在 completion 取消后启动。"""
|
|
|
|
coroutine = worker()
|
|
completion = registry.submit_threadsafe(
|
|
coroutine,
|
|
loop=loop,
|
|
owner="test.threadsafe-cancel-before-dispatch",
|
|
)
|
|
|
|
assert completion.cancel()
|
|
assert inspect.getcoroutinestate(coroutine) == inspect.CORO_CLOSED
|
|
loop.run_until_complete(asyncio.sleep(0))
|
|
|
|
assert registry.records == ()
|
|
loop.close()
|
|
|
|
|
|
def test_task_registry_closes_submission_when_loop_rejects_callback() -> None:
|
|
"""目标 loop 已关闭时同步失败,并且不遗留未等待协程。"""
|
|
registry = TaskRegistry()
|
|
loop = asyncio.new_event_loop()
|
|
loop.close()
|
|
|
|
async def worker() -> None:
|
|
"""模拟无法进入目标 loop 的提交。"""
|
|
|
|
coroutine = worker()
|
|
with pytest.raises(RuntimeError, match="closed"):
|
|
registry.submit_threadsafe(
|
|
coroutine,
|
|
loop=loop,
|
|
owner="test.threadsafe-closed-loop",
|
|
)
|
|
|
|
assert inspect.getcoroutinestate(coroutine) == inspect.CORO_CLOSED
|
|
|
|
|
|
def test_task_registry_shutdown_owns_close_before_loop_rejection() -> None:
|
|
"""shutdown 已取走 pending 后,迟到的投递失败不得再次关闭同一协程。"""
|
|
registry = TaskRegistry()
|
|
dispatch_entered = threading.Event()
|
|
release_dispatch = threading.Event()
|
|
coroutine = _CloseCountingCoroutine()
|
|
|
|
class BlockingClosedLoop:
|
|
"""让 shutdown 稳定发生在投递抛错之前。"""
|
|
|
|
@staticmethod
|
|
def call_soon_threadsafe(_callback) -> None:
|
|
dispatch_entered.set()
|
|
release_dispatch.wait()
|
|
raise RuntimeError("loop closed")
|
|
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
|
submission = executor.submit(
|
|
registry.submit_threadsafe,
|
|
coroutine,
|
|
loop=BlockingClosedLoop(),
|
|
owner="test.threadsafe-shutdown-before-rejection",
|
|
)
|
|
assert dispatch_entered.wait(1.0)
|
|
assert asyncio.run(registry.shutdown(timeout_seconds=0.01)) is True
|
|
release_dispatch.set()
|
|
with pytest.raises(RuntimeError, match="loop closed"):
|
|
submission.result(timeout=1.0)
|
|
|
|
assert coroutine.close_count == 1
|
|
|
|
|
|
def test_task_registry_shutdown_rejects_late_successful_dispatch() -> None:
|
|
"""shutdown 取走 pending 后,迟到的成功投递也不得发布真实 Task。"""
|
|
registry = TaskRegistry()
|
|
dispatch_entered = threading.Event()
|
|
release_dispatch = threading.Event()
|
|
callbacks = []
|
|
coroutine = _CloseCountingCoroutine()
|
|
|
|
class BlockingLoop:
|
|
"""让 shutdown 稳定发生在 loop 接受 callback 之前。"""
|
|
|
|
@staticmethod
|
|
def call_soon_threadsafe(callback) -> None:
|
|
dispatch_entered.set()
|
|
release_dispatch.wait()
|
|
callbacks.append(callback)
|
|
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
|
submission = executor.submit(
|
|
registry.submit_threadsafe,
|
|
coroutine,
|
|
loop=BlockingLoop(),
|
|
owner="test.threadsafe-shutdown-before-dispatch",
|
|
)
|
|
assert dispatch_entered.wait(1.0)
|
|
assert asyncio.run(registry.shutdown(timeout_seconds=0.01)) is True
|
|
release_dispatch.set()
|
|
completion = submission.result(timeout=1.0)
|
|
|
|
assert completion.cancelled()
|
|
callbacks[0]()
|
|
assert coroutine.close_count == 1
|
|
assert registry.records == ()
|
|
|
|
|
|
def test_task_registry_completion_does_not_retain_itself_after_cancellation() -> None:
|
|
"""终态回调不得让已取消 completion 依赖循环 GC 才能释放。"""
|
|
registry = TaskRegistry()
|
|
loop = asyncio.new_event_loop()
|
|
completion = registry.submit_threadsafe(
|
|
_CloseCountingCoroutine(),
|
|
loop=loop,
|
|
owner="test.threadsafe-completion-release",
|
|
)
|
|
completion_ref = weakref.ref(completion)
|
|
|
|
assert completion.cancel()
|
|
loop.run_until_complete(asyncio.sleep(0))
|
|
del completion
|
|
|
|
assert completion_ref() is None
|
|
loop.close()
|
|
|
|
|
|
def test_task_registry_pending_non_cancellable_work_does_not_start_on_shutdown() -> None:
|
|
"""尚未发布成 Task 的 non-cancellable 工作仍应在关停时直接关闭。"""
|
|
registry = TaskRegistry()
|
|
loop = asyncio.new_event_loop()
|
|
|
|
async def worker() -> None:
|
|
"""未开始的同步兼容工作不应拖延关停。"""
|
|
|
|
coroutine = worker()
|
|
completion = registry.submit_threadsafe(
|
|
coroutine,
|
|
loop=loop,
|
|
owner="test.threadsafe-pending-non-cancellable",
|
|
cancel_on_shutdown=False,
|
|
)
|
|
|
|
assert asyncio.run(registry.shutdown(timeout_seconds=0.01)) is True
|
|
assert completion.cancelled()
|
|
assert inspect.getcoroutinestate(coroutine) == inspect.CORO_CLOSED
|
|
|
|
loop.run_until_complete(asyncio.sleep(0))
|
|
assert registry.records == ()
|
|
loop.close()
|
|
|
|
|
|
def test_task_registry_keeps_timed_out_sync_owner_until_real_completion() -> None:
|
|
"""同步线程超过关停预算后仍应保留 owner,不能把包装任务取消成伪完成。"""
|
|
|
|
async def scenario() -> None:
|
|
registry = TaskRegistry()
|
|
started = threading.Event()
|
|
release = threading.Event()
|
|
reports: list[dict[str, object]] = []
|
|
loop = asyncio.get_running_loop()
|
|
previous_handler = loop.get_exception_handler()
|
|
loop.set_exception_handler(lambda _, context: reports.append(context))
|
|
|
|
def worker() -> None:
|
|
"""模拟无法由 asyncio 取消、需要外部资源自行结束的同步工作。"""
|
|
started.set()
|
|
release.wait()
|
|
|
|
try:
|
|
task = registry.create_sync(worker, owner="test.sync-timeout")
|
|
assert await asyncio.to_thread(started.wait, 1.0)
|
|
|
|
assert await registry.shutdown(timeout_seconds=0.001) is False
|
|
|
|
assert not task.done()
|
|
assert [record.owner for record in registry.records] == [
|
|
"test.sync-timeout"
|
|
]
|
|
assert reports[-1]["owners"] == ("test.sync-timeout",)
|
|
|
|
release.set()
|
|
await task
|
|
await asyncio.sleep(0)
|
|
assert registry.records == ()
|
|
finally:
|
|
release.set()
|
|
loop.set_exception_handler(previous_handler)
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_task_registry_keeps_stubborn_cancelled_task_visible() -> None:
|
|
"""协程清理超过预算时只收一次取消,并在最终退出后自动清理。"""
|
|
|
|
async def scenario() -> None:
|
|
registry = TaskRegistry()
|
|
started = asyncio.Event()
|
|
cleanup_started = asyncio.Event()
|
|
release = asyncio.Event()
|
|
cancellation_count = 0
|
|
reports: list[dict[str, object]] = []
|
|
loop = asyncio.get_running_loop()
|
|
previous_handler = loop.get_exception_handler()
|
|
loop.set_exception_handler(lambda _, context: reports.append(context))
|
|
|
|
async def worker() -> None:
|
|
"""模拟收到取消后仍必须完成的异步清理。"""
|
|
nonlocal cancellation_count
|
|
started.set()
|
|
try:
|
|
await asyncio.Event().wait()
|
|
except asyncio.CancelledError:
|
|
cancellation_count += 1
|
|
cleanup_started.set()
|
|
await release.wait()
|
|
|
|
task = registry.create(worker(), owner="test.stubborn")
|
|
await started.wait()
|
|
try:
|
|
assert await registry.shutdown(timeout_seconds=0.001) is False
|
|
|
|
assert cleanup_started.is_set()
|
|
assert not task.done()
|
|
assert cancellation_count == 1
|
|
assert [record.owner for record in registry.records] == [
|
|
"test.stubborn"
|
|
]
|
|
assert reports[-1]["owners"] == ("test.stubborn",)
|
|
|
|
assert await registry.shutdown(timeout_seconds=0.001) is False
|
|
assert not task.done()
|
|
assert cancellation_count == 1
|
|
assert len(reports) == 1
|
|
|
|
release.set()
|
|
await task
|
|
await asyncio.sleep(0)
|
|
assert registry.records == ()
|
|
finally:
|
|
release.set()
|
|
loop.set_exception_handler(previous_handler)
|
|
|
|
asyncio.run(scenario())
|