Files
MoviePilot/tests/test_plugin_lifecycle_concurrency.py

120 lines
4.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""插件生命周期入口并发交错时的运行实例归属测试。"""
import threading
from unittest.mock import MagicMock
from app.runtime.extensions.plugin.lifecycle import PluginLifecycle
# 停止一侧在停机 hook 里等待加载一侧的预算。start 不持锁时加载会立刻完成并置位,
# 等待即刻返回start 持锁时加载被挡在锁外,这里必然等满。两种实现下交错顺序都是
# 确定的,用例不依赖线程被调度的先后。
_INTERLEAVE_BUDGET = 0.3
# 线程握手与回收的宽松预算,仅用于避免实现回归时把整个测试进程挂死
_HANDSHAKE_BUDGET = 5.0
class _SeedInstance:
"""代表上一轮加载留下的运行实例,并在停机 hook 中把执行权让给加载线程。"""
def __init__(
self,
*,
stopped: list,
quiesce_entered: threading.Event,
start_finished: threading.Event,
) -> None:
"""记录停机事实,并保存与加载线程握手用的两个事件。"""
self._stopped = stopped
self._quiesce_entered = quiesce_entered
self._start_finished = start_finished
def stop_service(self) -> None:
"""登记自身已被停止,并在停止事务中途给加载线程留出执行窗口。"""
self._stopped.append(self)
self._quiesce_entered.set()
self._start_finished.wait(_INTERLEAVE_BUDGET)
def _plugin_class(constructed: list) -> type:
"""构造满足最小生命周期合同、并登记自身实例化事实的插件类。"""
class DemoPlugin:
"""提供并发交错用例需要的最小插件行为。"""
plugin_name = "演示插件"
plugin_version = "1.0.0"
def init_plugin(self, _config) -> None:
"""登记本实例已完成初始化,等价于其后台服务已经拉起。"""
constructed.append(self)
@staticmethod
def get_state() -> bool:
"""并发用例只关心启用态插件,恒定返回启用。"""
return True
return DemoPlugin
def _lifecycle(*, plugin_class: type, running: dict) -> PluginLifecycle:
"""构造隔离事件、模块清理和数据库的生命周期实例。"""
return PluginLifecycle(
classes={"DemoPlugin": plugin_class},
running=running,
load_plugins=lambda _plugin_id, _loadable, _check: [plugin_class],
loadable_plugins=lambda: ["DemoPlugin"],
plugin_config=lambda _plugin_id: {},
auth_checker=lambda _plugin: True,
clear_modules=MagicMock(),
clear_tools=MagicMock(),
enable_events=MagicMock(),
disable_events=MagicMock(),
runtime_status_writer=lambda _plugin_id, _status: None,
database=lambda: MagicMock(),
log=MagicMock(),
event_sender=MagicMock(),
)
def test_concurrent_start_does_not_orphan_the_instance_it_loads() -> None:
"""加载与停止交错时,被加载出来的实例不能既不在运行表也没被停止。
停止取的是进入时的运行表快照,卸载却按插件 ID 清运行表。start 不与 stop 互斥
时,交错窗口里加载出来的实例会被这次卸载一并抹掉,而它的 stop_service 从未被
调用——它注册的定时任务、线程和事件订阅继续在跑,宿主却再也拿不到句柄停它。
"""
constructed: list = []
stopped: list = []
quiesce_entered = threading.Event()
start_finished = threading.Event()
seed = _SeedInstance(
stopped=stopped,
quiesce_entered=quiesce_entered,
start_finished=start_finished,
)
running: dict = {"DemoPlugin": seed}
lifecycle = _lifecycle(plugin_class=_plugin_class(constructed), running=running)
def load() -> None:
"""等停止进入停机 hook 之后再发起加载,制造确定的交错顺序。"""
assert quiesce_entered.wait(_HANDSHAKE_BUDGET)
lifecycle.start("DemoPlugin")
start_finished.set()
loader = threading.Thread(target=load, name="plugin-start", daemon=True)
loader.start()
try:
lifecycle.stop("DemoPlugin")
finally:
loader.join(_HANDSHAKE_BUDGET)
assert not loader.is_alive()
assert stopped == [seed]
assert len(constructed) == 1
orphaned = [
instance
for instance in constructed
if instance not in running.values() and instance not in stopped
]
assert orphaned == []