Files
MoviePilot/tests/test_monitor_lifecycle.py
T

346 lines
12 KiB
Python

"""目录监控 owner 的生命周期与停机屏障回归。"""
import asyncio
import threading
import time
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from app.monitor.monitor import Monitor
from app.monitor.recovery import RecoveryExecutor, RecoveryState
from app.foundation.singleton import SingletonClass
from app.startup.initializers.monitor import init_monitor, stop_monitor
def _build_monitor() -> Monitor:
"""构造绕过单例初始化、但具备完整生命周期字段的 Monitor 骨架。"""
monitor = object.__new__(Monitor)
monitor._lifecycle_lock = threading.RLock()
monitor._owner_lock = threading.Lock()
monitor._work_stop_event = threading.Event()
monitor._shutdown_event = threading.Event()
monitor._closed = False
monitor._compensation_threads = {}
monitor._scheduler_shutdown_thread = None
monitor._scheduler_shutdown_succeeded = False
monitor._scheduler = None
monitor._watchers = []
monitor._retired_watchers = []
monitor._watcher_lock = threading.Lock()
monitor._pending_locals = []
monitor._alerted_paths = {}
monitor._restart_marks = {}
monitor._stable_cycles = {}
monitor._isolated = {}
monitor._pending_rebuild = {}
monitor._recovery = RecoveryExecutor()
monitor._dispatcher = MagicMock()
return monitor
def _blocking_watcher(mon_path: Path):
"""构造只有显式 release 后才退出的 watcher 与控制事件。"""
started = threading.Event()
release = threading.Event()
def run() -> None:
"""模拟无法被 stop event 唤醒的 FUSE watcher。"""
started.set()
release.wait()
thread = threading.Thread(target=run, daemon=True, name="test-monitor-watcher")
thread.start()
assert started.wait(1)
watcher = MagicMock()
watcher.watch_path = mon_path
watcher.stop.side_effect = lambda: None
watcher.join.side_effect = thread.join
watcher.is_alive.side_effect = thread.is_alive
return watcher, thread, release
def test_close_budget_includes_lifecycle_lock_wait() -> None:
"""停机 deadline 必须先于生命周期锁等待建立,锁竞争超时后立即返回。"""
monitor = _build_monitor()
lock_acquired = threading.Event()
release_lock = threading.Event()
def hold_lifecycle_lock() -> None:
"""占住生命周期锁,模拟配置重载阻塞在挂载访问中。"""
with monitor._lifecycle_lock:
lock_acquired.set()
release_lock.wait()
holder = threading.Thread(target=hold_lifecycle_lock, daemon=True)
holder.start()
assert lock_acquired.wait(1)
started_at = time.monotonic()
try:
assert monitor.close(timeout=0.02) is False
assert time.monotonic() - started_at < 0.5
assert monitor.lifecycle_closed is True
finally:
release_lock.set()
holder.join(timeout=1)
assert holder.is_alive() is False
assert monitor.close(timeout=1) is True
def test_close_timeout_retains_watcher_and_seals_config_reload(tmp_path) -> None:
"""挂死 watcher 超时时保留句柄,且配置变化不能重开已封口生命周期。"""
monitor = _build_monitor()
watcher, thread, release = _blocking_watcher(tmp_path)
monitor._watchers = [watcher]
reload_monitor = MagicMock()
monitor.init = reload_monitor
try:
assert monitor.close(timeout=0.02) is False
assert monitor._watchers == [watcher]
monitor._dispatcher.clear_pending.assert_not_called()
monitor.on_config_changed()
reload_monitor.assert_not_called()
finally:
release.set()
assert monitor.close(timeout=1) is True
thread.join(timeout=1)
assert thread.is_alive() is False
assert monitor._watchers == []
monitor._dispatcher.clear_pending.assert_called_once_with()
def test_reopen_allows_only_an_explicit_new_lifespan() -> None:
"""close 后普通 init 被拒绝,显式 reopen 收敛旧 owner 后才允许重新初始化。"""
monitor = _build_monitor()
initialize = MagicMock(return_value=True)
monitor._Monitor__initialize_monitors = initialize
assert monitor.close(timeout=1) is True
assert monitor.init(timeout=0) is False
initialize.assert_not_called()
assert monitor.reopen(timeout=1) is True
assert monitor.init(timeout=1) is True
initialize.assert_called_once_with()
assert monitor.lifecycle_closed is False
def test_close_tracks_recovery_and_blocks_post_seal_dispatch() -> None:
"""恢复动作解冻后只能退出,不能越过封口继续派发整理重试。"""
monitor = _build_monitor()
entered = threading.Event()
release = threading.Event()
def blocking_local_retry() -> None:
"""模拟恢复线程阻塞在 FUSE 本地目录访问。"""
entered.set()
release.wait()
monitor._Monitor__retry_pending_locals = blocking_local_retry
result = monitor._recovery.run(
{monitor.PENDING_KEY: monitor._Monitor__drive_pending}, timeout=0.01
)
assert entered.is_set()
assert result == {monitor.PENDING_KEY: RecoveryState.TIMEOUT}
try:
assert monitor.close(timeout=0.02) is False
assert len(monitor._recovery.running_threads()) == 1
monitor._dispatcher.retry_pending.assert_not_called()
finally:
release.set()
assert monitor.close(timeout=1) is True
assert monitor._recovery.running_threads() == ()
monitor._dispatcher.retry_pending.assert_not_called()
def test_recovery_start_and_registration_are_atomic(monkeypatch) -> None:
"""close 不得在恢复线程登记后、真正 start 前把它误判为已收敛。"""
executor = RecoveryExecutor()
start_entered = threading.Event()
allow_start = threading.Event()
close_results: list[bool] = []
original_start = threading.Thread.start
def delayed_recovery_start(thread: threading.Thread) -> None:
"""只暂停恢复线程的 start,稳定放大登记与启动之间的竞态窗口。"""
if thread.name.startswith("MoviePilot-MonitorRecovery-"):
start_entered.set()
assert allow_start.wait(timeout=1)
original_start(thread)
monkeypatch.setattr(threading.Thread, "start", delayed_recovery_start)
runner = threading.Thread(
target=lambda: executor.run({"atomic": lambda: None}, timeout=1),
name="recovery-submit-test",
)
runner.start()
assert start_entered.wait(timeout=1)
closer = threading.Thread(
target=lambda: close_results.append(
executor.close(deadline=time.monotonic() + 1)
),
name="recovery-close-test",
)
closer.start()
closer.join(timeout=0.05)
assert closer.is_alive(), "close 越过了尚未完成 start 的 owner 临界区"
allow_start.set()
runner.join(timeout=1)
closer.join(timeout=1)
assert not runner.is_alive()
assert not closer.is_alive()
assert close_results == [True]
assert executor.running_threads() == ()
def test_close_tracks_compensation_and_blocks_post_seal_dispatch(tmp_path) -> None:
"""补偿扫描解冻后不得在旧 lifespan 中继续把文件送入整理链。"""
monitor = _build_monitor()
entered = threading.Event()
release = threading.Event()
candidate = tmp_path / "late.mkv"
def blocking_collect(_mon_path: Path):
"""模拟补偿扫描阻塞在目录遍历。"""
entered.set()
release.wait()
return [(candidate, time.time(), 1)]
monitor._Monitor__collect_compensation_files = blocking_collect
monitor._Monitor__start_compensation(mon_path=tmp_path, since=time.time())
assert entered.wait(1)
try:
assert monitor.close(timeout=0.02) is False
assert len(monitor._compensation_threads) == 1
monitor._dispatcher.handle_file.assert_not_called()
finally:
release.set()
assert monitor.close(timeout=1) is True
assert monitor._compensation_threads == {}
monitor._dispatcher.handle_file.assert_not_called()
def test_close_tracks_scheduler_shutdown_thread() -> None:
"""scheduler shutdown 阻塞时保留线程和 scheduler,恢复后才能清空句柄。"""
monitor = _build_monitor()
entered = threading.Event()
release = threading.Event()
scheduler = MagicMock()
scheduler.running = True
def blocking_shutdown(*, wait: bool) -> None:
"""模拟等待在途 APScheduler job 的同步 shutdown。"""
assert wait is True
entered.set()
release.wait()
scheduler.shutdown.side_effect = blocking_shutdown
monitor._scheduler = scheduler
try:
assert monitor.close(timeout=0.02) is False
assert entered.is_set()
assert monitor._scheduler is scheduler
assert monitor._scheduler_shutdown_thread is not None
assert monitor._scheduler_shutdown_thread.is_alive()
finally:
release.set()
assert monitor.close(timeout=1) is True
assert monitor._scheduler is None
assert monitor._scheduler_shutdown_thread is None
@pytest.mark.asyncio
async def test_stop_monitor_cancellation_waits_for_sync_close(monkeypatch) -> None:
"""生命周期取消异步 stop 后,线程池调用仍由任务持有到同步 close 结束。"""
entered = threading.Event()
release = threading.Event()
def blocking_close(timeout: float) -> bool:
"""模拟仍在同步收尾的 Monitor.close。"""
assert timeout == 1
entered.set()
release.wait()
return True
monitor = SimpleNamespace(close=blocking_close)
monkeypatch.setattr(Monitor, "get_existing_instance", lambda: monitor)
task = asyncio.create_task(stop_monitor(timeout=1))
while not entered.is_set():
await asyncio.sleep(0)
task.cancel()
await asyncio.sleep(0.01)
assert task.done() is False
release.set()
with pytest.raises(asyncio.CancelledError):
await task
def test_init_monitor_explicitly_reopens_existing_lifespan(monkeypatch) -> None:
"""同进程测试中的新 lifespan 必须显式 reopen,再初始化同一个 Monitor。"""
monitor = SimpleNamespace(
lifecycle_closed=True,
reopen=MagicMock(return_value=True),
init=MagicMock(return_value=True),
)
monkeypatch.setattr(Monitor, "get_existing_instance", lambda: monitor)
init_monitor()
monitor.reopen.assert_called_once_with(timeout=Monitor.RELOAD_STOP_TIMEOUT)
monitor.init.assert_called_once_with(timeout=Monitor.RELOAD_STOP_TIMEOUT)
@pytest.mark.asyncio
async def test_constructor_failure_publishes_started_watcher_to_cleanup(
monkeypatch,
) -> None:
"""Monitor 构造中途失败后,stop-only 入口仍必须找到已启动 watcher owner。"""
instances = dict(SingletonClass._instances)
instances.pop(Monitor, None)
monkeypatch.setattr(SingletonClass, "_instances", instances)
watcher_started = threading.Event()
watcher_release = threading.Event()
watchers: list[threading.Thread] = []
def failing_init(monitor: Monitor) -> None:
"""模拟 watcher 已启动、后续 scheduler 启动失败的构造过程。"""
watcher = threading.Thread(
target=lambda: (watcher_started.set(), watcher_release.wait()),
name="monitor-partial-construction",
daemon=True,
)
watchers.append(watcher)
watcher.start()
def close(timeout: float) -> bool:
"""模拟真实 close 释放并等待半构造实例已经发布的 watcher。"""
watcher_release.set()
watcher.join(timeout=timeout)
return not watcher.is_alive()
monitor.close = close
raise RuntimeError("scheduler start failed")
monkeypatch.setattr(Monitor, "__init__", failing_init)
with pytest.raises(RuntimeError, match="scheduler start failed"):
Monitor()
assert watcher_started.wait(timeout=1)
retained = Monitor.get_existing_instance()
assert retained is not None
assert await stop_monitor(timeout=1) is True
assert watchers[0].is_alive() is False