mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
* fix(resource): select free-threaded extension ABI * chore(deps): require moviepilot-rust 0.2.9 * perf: add free-threaded runtime comparison * feat: add free-threaded runtime profile * chore(deps): require moviepilot-rust 0.3.0 * test: isolate system endpoint import graph * fix(docker): preserve runtime profile during recovery * feat(runtime): expose active GIL state * feat(plugin): log GIL fallback attribution * test: refresh runtime observability dependency baseline * fix(plugin): validate the active uv runtime profile * test(runtime): expand free-threaded benchmark evidence * docs(runtime): define v3t governance gates * test(runtime): separate rust benchmark modes * docs: sync free-threaded architecture baseline * perf: add PostgreSQL driver comparison * docs(runtime): record final free-threaded evidence * fix(runtime): converge dual-profile dependency verification * fix(runtime): scope Python 3.14 warning filter * fix(runtime): match actual oss2 syntax warning * docs(runtime): refresh free-threaded benchmark evidence * test(architecture): refresh runtime dependency baseline * ci: skip unused Trivy Java database * build: exclude local verification artifacts * docs(runtime): refresh PostgreSQL driver benchmarks * docs(runtime): record plugin restore acceptance * docs(runtime): record amd64 candidate acceptance * ci: pin beta image publisher action * test(architecture): merge runtime dependency baseline * feat(runtime): expose Python GIL status * docs(runtime): document Python runtime status fields
262 lines
9.6 KiB
Python
262 lines
9.6 KiB
Python
"""插件可变事务停机准入的确定性测试。"""
|
|
|
|
import asyncio
|
|
import threading
|
|
from collections.abc import Iterator
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from contextvars import copy_context
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from app.foundation.singleton import Singleton
|
|
from app.runtime.extensions.plugin.admission import (
|
|
PluginMutationAdmission,
|
|
PluginMutationRejectedError,
|
|
)
|
|
from app.runtime.extensions.plugin.system import reset_plugin_system
|
|
from app.runtime.extensions.plugin_manager import PluginManager
|
|
from app.schemas.plugin import PluginRuntimeStatus
|
|
from app.schemas.types import EventType
|
|
|
|
|
|
class _GatedCondition:
|
|
"""让指定测试线程在取得真实 Condition 前停住。"""
|
|
|
|
def __init__(self, blocked_thread_prefix: str) -> None:
|
|
"""初始化内部 Condition 和可控的竞态闸门。"""
|
|
self._condition = threading.Condition()
|
|
self._blocked_thread_prefix = blocked_thread_prefix
|
|
self._blocked = False
|
|
self.attempted = threading.Event()
|
|
self.release = threading.Event()
|
|
|
|
def __enter__(self):
|
|
"""在目标线程首次进入时等待测试放行。"""
|
|
if (
|
|
threading.current_thread().name.startswith(self._blocked_thread_prefix)
|
|
and not self._blocked
|
|
):
|
|
self._blocked = True
|
|
self.attempted.set()
|
|
if not self.release.wait(timeout=1):
|
|
raise TimeoutError("测试未及时放行 Condition")
|
|
return self._condition.__enter__()
|
|
|
|
def __exit__(self, exc_type, exc_value, traceback):
|
|
"""把上下文退出委托给内部 Condition。"""
|
|
return self._condition.__exit__(exc_type, exc_value, traceback)
|
|
|
|
def wait(self) -> bool:
|
|
"""等待 admission 活动计数变化。"""
|
|
return self._condition.wait()
|
|
|
|
def notify_all(self) -> None:
|
|
"""唤醒全部等待 admission 空闲的线程。"""
|
|
self._condition.notify_all()
|
|
|
|
|
|
@pytest.fixture
|
|
def plugin_manager() -> Iterator[PluginManager]:
|
|
"""构造隔离的插件管理器,并在用例结束后清除单例状态。"""
|
|
key = (PluginManager, (), frozenset())
|
|
Singleton._instances.pop(key, None)
|
|
reset_plugin_system()
|
|
manager = PluginManager()
|
|
try:
|
|
yield manager
|
|
finally:
|
|
Singleton._instances.pop(key, None)
|
|
|
|
|
|
def test_seal_rejects_new_root_but_allows_propagated_nested_lease() -> None:
|
|
"""封口拒绝无关调用,但已获准事务跨线程后的嵌套仍能完成。"""
|
|
admission = PluginMutationAdmission()
|
|
calls: list[str] = []
|
|
|
|
def mutate(label: str) -> None:
|
|
"""在测试线程中尝试取得 lease 并记录副作用。"""
|
|
with admission.hold(label):
|
|
calls.append(label)
|
|
|
|
with admission.hold("外层事务"):
|
|
propagated = copy_context()
|
|
assert admission.active_count == 1
|
|
assert admission.seal() == 1
|
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
executor.submit(propagated.run, mutate, "嵌套事务").result(timeout=1)
|
|
rejected = executor.submit(mutate, "新事务")
|
|
with pytest.raises(PluginMutationRejectedError):
|
|
rejected.result(timeout=1)
|
|
|
|
assert calls == ["嵌套事务"]
|
|
assert admission.active_count == 1
|
|
|
|
assert admission.active_count == 0
|
|
assert admission.reopen() is True
|
|
|
|
|
|
def test_stale_propagated_context_is_rechecked_atomically_after_seal() -> None:
|
|
"""外层退出后才取得 Condition 的复制上下文不得冒充嵌套事务。"""
|
|
admission = PluginMutationAdmission()
|
|
calls: list[str] = []
|
|
outer = admission.hold("外层事务")
|
|
outer.__enter__()
|
|
propagated = copy_context()
|
|
condition = _GatedCondition("stale-admission")
|
|
admission._condition = condition
|
|
outer_closed = False
|
|
|
|
def mutate() -> None:
|
|
"""使用复制上下文尝试执行延迟到封口后的写入。"""
|
|
with admission.hold("延迟嵌套事务"):
|
|
calls.append("mutated")
|
|
|
|
try:
|
|
with ThreadPoolExecutor(
|
|
max_workers=1,
|
|
thread_name_prefix="stale-admission",
|
|
) as executor:
|
|
future = executor.submit(propagated.run, mutate)
|
|
assert condition.attempted.wait(timeout=1)
|
|
outer.__exit__(None, None, None)
|
|
outer_closed = True
|
|
assert admission.seal() == 0
|
|
admission.wait_until_idle()
|
|
|
|
condition.release.set()
|
|
with pytest.raises(PluginMutationRejectedError):
|
|
future.result(timeout=1)
|
|
finally:
|
|
condition.release.set()
|
|
if not outer_closed:
|
|
outer.__exit__(None, None, None)
|
|
|
|
assert calls == []
|
|
assert admission.active_count == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quiesce_timeout_retains_admitted_owner_and_nested_reload(
|
|
plugin_manager: PluginManager,
|
|
monkeypatch,
|
|
) -> None:
|
|
"""停机超时保留 active owner,且封口前获准的事务可完成内部重载。"""
|
|
manager = plugin_manager
|
|
entered = asyncio.Event()
|
|
release = asyncio.Event()
|
|
manager._plugin_lifecycle.reload = MagicMock(
|
|
return_value=PluginRuntimeStatus.ACTIVE
|
|
)
|
|
manager._plugin_lifecycle.quiesce_handlers = MagicMock(return_value=True)
|
|
manager._plugin_lifecycle.finalize = MagicMock(return_value=True)
|
|
|
|
async def mutate() -> PluginRuntimeStatus:
|
|
"""持有外层 lease,等待封口后再调用嵌套 Manager 写入口。"""
|
|
with manager.mutation("安装插件 DemoPlugin"):
|
|
entered.set()
|
|
await release.wait()
|
|
return manager.reload_plugin("DemoPlugin")
|
|
|
|
with ThreadPoolExecutor(max_workers=1) as executor:
|
|
monkeypatch.setattr(
|
|
"app.runtime.extensions.plugin_manager.ThreadHelper",
|
|
lambda: SimpleNamespace(submit=executor.submit),
|
|
)
|
|
mutation_task = asyncio.create_task(mutate())
|
|
await entered.wait()
|
|
|
|
assert await manager.quiesce_plugins(timeout=0.01) is False
|
|
owner = manager._plugin_quiesce_future
|
|
assert owner is not None
|
|
assert owner.done() is False
|
|
assert manager._plugin_mutation_admission.active_count == 1
|
|
assert manager.finalize_plugins() is False
|
|
with pytest.raises(PluginMutationRejectedError):
|
|
with manager.mutation("新的配置写入"):
|
|
pass
|
|
|
|
release.set()
|
|
assert await mutation_task is PluginRuntimeStatus.ACTIVE
|
|
assert await asyncio.wrap_future(owner) is True
|
|
|
|
manager._plugin_lifecycle.reload.assert_called_once_with(
|
|
"DemoPlugin",
|
|
EventType.PluginReload,
|
|
)
|
|
assert manager._plugin_mutation_admission.active_count == 0
|
|
assert manager.finalize_plugins() is True
|
|
|
|
|
|
def test_reload_attributes_gil_transition_to_plugin(
|
|
plugin_manager: PluginManager,
|
|
monkeypatch,
|
|
) -> None:
|
|
"""运行期插件加载导致 GIL 退化时应记录插件归因。"""
|
|
states = iter((False, True))
|
|
plugin_manager._plugin_lifecycle.reload = MagicMock(
|
|
return_value=PluginRuntimeStatus.ACTIVE
|
|
)
|
|
warning = MagicMock()
|
|
monkeypatch.setattr(
|
|
"app.runtime.extensions.plugin_manager.is_free_threaded_runtime",
|
|
lambda: True,
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.runtime.extensions.plugin_manager.is_gil_enabled",
|
|
lambda: next(states),
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.runtime.extensions.plugin_manager.logger.warning",
|
|
warning,
|
|
)
|
|
|
|
assert plugin_manager.reload_plugin("DemoPlugin") is PluginRuntimeStatus.ACTIVE
|
|
|
|
warning.assert_called_once()
|
|
assert warning.call_args.args[1] == "DemoPlugin"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quiesce_inside_mutation_fails_without_sealing(
|
|
plugin_manager: PluginManager,
|
|
) -> None:
|
|
"""事务不能等待自身退出,快速失败时也不得误封口运行时。"""
|
|
with plugin_manager.mutation("配置插件"):
|
|
assert await plugin_manager.quiesce_plugins(timeout=0) is False
|
|
assert plugin_manager._plugin_mutation_admission.accepting is True
|
|
assert plugin_manager._plugin_runtime_closed is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sealed_manager_blocks_direct_persistent_mutations(
|
|
plugin_manager: PluginManager,
|
|
) -> None:
|
|
"""Manager 单项兼容入口在封口后返回既有失败形状且不触发底层副作用。"""
|
|
plugin_manager._plugin_config_store = MagicMock()
|
|
plugin_manager._plugin_instance_store = MagicMock()
|
|
plugin_manager._plugin_sync = MagicMock()
|
|
plugin_manager._plugin_clone = MagicMock()
|
|
plugin_manager._plugin_mutation_admission.seal()
|
|
plugin_manager._plugin_runtime_closed = True
|
|
|
|
assert plugin_manager.save_plugin_config("DemoPlugin", {}) is False
|
|
assert await plugin_manager.async_save_plugin_config("DemoPlugin", {}) is False
|
|
assert plugin_manager.delete_plugin_config("DemoPlugin") is False
|
|
assert plugin_manager.delete_plugin_data("DemoPlugin") is False
|
|
assert plugin_manager.delete_plugin_instance("DemoPlugin") is False
|
|
clone_result = plugin_manager.clone_plugin("DemoPlugin", "Work", "", "")
|
|
assert clone_result[0] is False
|
|
assert "停机阶段" in clone_result[1]
|
|
with pytest.raises(PluginMutationRejectedError):
|
|
plugin_manager.sync()
|
|
|
|
plugin_manager._plugin_config_store.write.assert_not_called()
|
|
plugin_manager._plugin_config_store.async_write.assert_not_called()
|
|
plugin_manager._plugin_config_store.delete.assert_not_called()
|
|
plugin_manager._plugin_config_store.delete_data.assert_not_called()
|
|
plugin_manager._plugin_instance_store.delete.assert_not_called()
|
|
plugin_manager._plugin_sync.sync.assert_not_called()
|
|
plugin_manager._plugin_clone.clone.assert_not_called()
|