Files
MoviePilot/tests/test_monitor_mount_isolation.py
T
jxxghp 02f0dd0b9a refactor(architecture): 修复模块依赖违规并强化架构守护
- 字幕编排上移 DownloadChain.download_site_subtitles,SubtitleModule 仅保留站点链接解析
- TransferChain.recommend_name 上移 TV episodes_info 获取,filemanager 模块不再导入 TmdbChain
- endpoint 穿透修复:WXBizMsgCrypt3 迁至 adapters/external/wechat_crypt.py;
  music/tmdb 缓存管理、listenbrainz 常量、TMDbException、WechatClawBot 辅助统一经 chain 包装
- RuleParser 与 builtin_rules 合并为 application/filter_rules.py;
  fsproxy/fsworker 迁至 adapters/system/
- chain/__init__.py 删除 qbittorrentapi/transmission_rpc 导入,消除后端协议类型泄漏
- 架构守护测试新增三项检查:模块间隔离、入口层穿透、下载器 SDK 泄漏
- 文档同步:05-architecture.md 记录 DB/Oper 聚合例外与迁移文件位置,AGENTS.md 更新所有权表
2026-08-16 04:59:17 +08:00

564 lines
21 KiB
Python

"""
挂载级(block 型)故障下的监控自愈测试。
背景:CloudDrive2/115 等 FUSE 挂载会进入「请求不返回错误、永不返回」的挂死
状态。此时监控自愈体系的检测环节仍然有效,但恢复环节(重建监控线程、重试
整理队列)本身要访问挂载,一旦内联执行就会把全局自愈的单点——健康检查——
冻死在它自己要修复的挂载上,随后停滞检测、告警、重试驱动全部静默失效。
crash 型(挂载抛 Transport endpoint is not connected)的自愈由
test_monitor_resilience.py 覆盖;这些测试固定 block 型的两项不变量:
「看门狗零挂载访问」与「挂载级故障隔离/探测/恢复」。
"""
import threading
import time
from pathlib import Path
from threading import Lock, Thread
from unittest.mock import MagicMock
import pytest
from app.monitor import LocalDirectoryWatcher, Monitor
from app.monitor.recovery import RecoveryExecutor, RecoveryState, probe_path
def _build_monitor(monkeypatch, put_recorder=None):
"""
构造测试用 Monitor 骨架,绕过单例初始化。
:param monkeypatch: pytest monkeypatch
:param put_recorder: 消息推送记录器
:return: Monitor 骨架
"""
put_recorder = put_recorder or MagicMock()
monkeypatch.setattr("app.monitor.monitor.MessageHelper", MagicMock(return_value=put_recorder))
monitor = object.__new__(Monitor)
monitor._dispatcher = MagicMock()
monitor._watchers = []
monitor._watcher_lock = Lock()
monitor._pending_locals = []
monitor._alerted_paths = {}
monitor._restart_marks = {}
monitor._stable_cycles = {}
monitor._isolated = {}
monitor._pending_rebuild = {}
monitor._recovery = RecoveryExecutor()
return monitor
def _fake_watcher(mon_path, alive=True, stalled=False, restart_count=0):
"""
构造测试用监控线程替身。
:param mon_path: 监控目录
:param alive: 线程是否存活
:param stalled: 是否静默失效
:param restart_count: 自动重启次数
:return: 监控线程替身
"""
watcher = MagicMock()
watcher.watch_path = mon_path
watcher.is_alive.return_value = alive
watcher.is_stalled.return_value = stalled
watcher.restart_count = restart_count
watcher.last_activity_time = time.time()
return watcher
def _run_watchdog(monitor, timeout=10.0):
"""
在独立线程里跑一次健康检查,返回它是否在限定时间内结束。
:param monitor: Monitor 骨架
:param timeout: 最长等待秒数
:return: 健康检查是否已返回
"""
done = threading.Event()
def runner():
"""
执行一次健康检查并标记结束。
"""
try:
monitor.watchdog()
finally:
done.set()
thread = Thread(target=runner, daemon=True, name="test-watchdog")
thread.start()
finished = done.wait(timeout=timeout)
if finished:
thread.join(timeout=timeout)
return finished and not thread.is_alive()
def _release_and_join(release, threads, timeout=10.0):
"""
释放测试注入的阻塞,并等待受控工作线程结束。
真实故障下的恢复线程允许随进程退出,但测试主动解除阻塞后必须完成回收,
避免仍在执行应用代码的守护线程与解释器关闭过程并发。
:param release: 控制阻塞动作的事件
:param threads: 需要等待的线程集合
:param timeout: 每个线程的最长等待秒数
"""
release.set()
for thread in threads:
thread.join(timeout=timeout)
assert not thread.is_alive(), f"受控线程未能结束: {thread.name}"
def _stop_monitor_threads(monitor, timeout=10.0):
"""
停止测试期间重建的目录监控,并等待其派生任务结束。
目录 watcher 会进入 watchfiles 原生扩展,必须在解释器关闭前退出;补偿扫描
由重建流程派生,也要在测试边界内完成。
:param monitor: Monitor 骨架
:param timeout: 每个线程的最长等待秒数
"""
for watcher in monitor._watchers:
if isinstance(watcher, LocalDirectoryWatcher):
watcher.stop()
watcher.join(timeout=timeout)
assert not watcher.is_alive(), f"目录监控线程未能结束: {watcher.watch_path}"
for thread in threading.enumerate():
if thread.name.startswith("MoviePilot-MonitorCompensation-"):
thread.join(timeout=timeout)
assert not thread.is_alive(), f"补偿扫描线程未能结束: {thread.name}"
# --------------------------------------------------------------------------- #
# P0:看门狗线程零挂载访问
# --------------------------------------------------------------------------- #
def test_watchdog_returns_while_rebuild_blocks_forever(tmp_path, monkeypatch):
"""
根因回归:重建动作卡在死挂载上永不返回时,健康检查本身必须在有限时间内
返回。事故中看门狗内联执行重建,冻死后 13 个目录的停滞检测与告警全部失效。
"""
monkeypatch.setattr(Monitor, "RECOVERY_TIMEOUT", 0.5)
monitor = _build_monitor(monkeypatch)
watcher = _fake_watcher(tmp_path, alive=True, stalled=True)
monitor._watchers = [watcher]
entered = threading.Event()
release = threading.Event()
def blocking_rebuild(_target):
"""
模拟 block 型挂载:进入后永不返回。
"""
entered.set()
release.wait()
setattr(monitor, "_Monitor__rebuild_watcher", blocking_rebuild)
try:
assert _run_watchdog(monitor), "看门狗被重建动作冻死,未能在限定时间内返回"
assert entered.is_set(), "重建动作没有被真正发起"
finally:
_release_and_join(release, monitor._recovery._running.values())
def test_watchdog_returns_while_pending_retry_blocks_forever(tmp_path, monkeypatch):
"""
重试驱动(整理重试队列的 stat)同样会卡在死挂载上,也必须移出看门狗线程。
"""
monkeypatch.setattr(Monitor, "RECOVERY_TIMEOUT", 0.5)
monitor = _build_monitor(monkeypatch)
release = threading.Event()
monitor._dispatcher.retry_pending.side_effect = lambda: release.wait()
try:
assert _run_watchdog(monitor), "看门狗被整理重试驱动冻死,未能在限定时间内返回"
finally:
_release_and_join(release, monitor._recovery._running.values())
def test_watchdog_returns_while_local_retry_blocks_forever(tmp_path, monkeypatch):
"""
启动失败目录的重试会走 decide_monitor_mode 的 os.walk 与 watcher.start()
的 exists(),同样触碰挂载,必须移出看门狗线程。
"""
monkeypatch.setattr(Monitor, "RECOVERY_TIMEOUT", 0.5)
monitor = _build_monitor(monkeypatch)
monitor._pending_locals = [{"mon_path": tmp_path, "monitor_mode": "compatibility"}]
release = threading.Event()
def blocking_start(**_kwargs):
"""
模拟启动重试卡在死挂载的目录遍历上。
"""
release.wait()
return False
setattr(monitor, "_Monitor__start_local_monitor", blocking_start)
try:
assert _run_watchdog(monitor), "看门狗被本地监控重试冻死,未能在限定时间内返回"
finally:
_release_and_join(release, monitor._recovery._running.values())
def test_check_watchers_performs_no_filesystem_access(tmp_path, monkeypatch):
"""
检测与判定必须是纯内存运算:只读线程存活标志、心跳与重启计数,
不做任何文件系统访问,否则检测环节本身也会被 block 型故障拖死。
"""
monitor = _build_monitor(monkeypatch)
watcher = _fake_watcher(tmp_path, alive=True, stalled=True)
monitor._watchers = [watcher]
def forbidden(*_args, **_kwargs):
"""
任何真实的文件系统调用都应视为检测环节的缺陷。
"""
raise AssertionError("检测环节不允许访问文件系统")
for name in ("stat", "exists", "is_dir", "iterdir", "rglob"):
monkeypatch.setattr(Path, name, forbidden, raising=False)
broken = monitor._Monitor__check_watchers()
assert broken == [watcher]
def test_watchdog_survives_blocking_exists_in_real_rebuild(tmp_path, monkeypatch):
"""
端到端回归:不替换任何恢复逻辑,只让 Path.exists() 永不返回(等价于对 FUSE
守护进程 kill -STOP 后挂载的表现),走真实的
__rebuild_watcher -> LocalDirectoryWatcher.start() -> exists() 调用链。
事故当天冻结的就是这一行(watcher.py 的 start() 入口校验)。修复后看门狗
必须照常返回,并把该目录转入隔离。
"""
monkeypatch.setattr(Monitor, "RECOVERY_TIMEOUT", 0.5)
monitor = _build_monitor(monkeypatch)
# 用真实 watcher:只把底层线程换成替身,让它被判定为「存活但静默失效」
watcher = LocalDirectoryWatcher(tmp_path, callback=MagicMock(), force_polling=True)
watcher._thread = MagicMock()
watcher._thread.is_alive.return_value = True
watcher._mark_activity()
watcher._last_activity -= LocalDirectoryWatcher.STALL_TIMEOUT + 1
monitor._watchers = [watcher]
release = threading.Event()
def blocking_stat(path, *_args, **_kwargs):
"""
模拟 block 型挂载:监控目录的属性读取永不返回。
入口校验已改走子进程代理,因此在代理这一层注入阻塞。
"""
if Path(path) == tmp_path:
release.wait()
return {"size": 0, "mtime": 0.0, "is_dir": True, "is_file": False}
monkeypatch.setattr(
"app.adapters.system.fsproxy.fsproxy.stat", blocking_stat
)
monkeypatch.setattr("app.monitor.monitor.probe_path", lambda *_a, **_kw: False)
try:
assert _run_watchdog(monitor), "看门狗冻死在真实的重建调用链上"
assert str(tmp_path) in monitor._isolated, "重建无响应后目录没有转入隔离"
finally:
_release_and_join(release, monitor._recovery._running.values())
_stop_monitor_threads(monitor)
# --------------------------------------------------------------------------- #
# P0.5:挂载级故障隔离
# --------------------------------------------------------------------------- #
def test_rebuild_timeout_isolates_directory(tmp_path, monkeypatch):
"""
重建在挂载上超时未返回 = 挂载级故障:该目录转入隔离,后续周期不再对它
发起任何新的挂载访问,避免每 60 秒泄漏一个冻死的线程。
"""
monkeypatch.setattr(Monitor, "RECOVERY_TIMEOUT", 0.3)
monitor = _build_monitor(monkeypatch)
watcher = _fake_watcher(tmp_path, alive=True, stalled=True)
monitor._watchers = [watcher]
calls = []
release = threading.Event()
def blocking_rebuild(target):
"""
模拟 block 型挂载上的重建:进入后永不返回。
"""
calls.append(target)
release.wait()
setattr(monitor, "_Monitor__rebuild_watcher", blocking_rebuild)
monkeypatch.setattr("app.monitor.monitor.probe_path", lambda *_a, **_kw: False)
try:
assert _run_watchdog(monitor)
assert str(tmp_path) in monitor._isolated, "重建超时后目录没有转入隔离"
# 第二个周期:隔离中的目录不得再被提交重建
assert _run_watchdog(monitor)
assert len(calls) == 1, "隔离中的目录仍在被反复重建,会持续泄漏冻死的线程"
finally:
_release_and_join(release, monitor._recovery._running.values())
def test_isolated_directory_recovers_after_probe_succeeds(tmp_path, monkeypatch):
"""
隔离期间用可放弃的子进程探测挂载;探测通过即解除隔离、重建监控,
并复用既有的补偿扫描补回停摆期间落地的文件。
"""
monkeypatch.setattr(Monitor, "RECOVERY_TIMEOUT", 5.0)
monitor = _build_monitor(monkeypatch)
watcher = _fake_watcher(tmp_path, alive=True, stalled=True)
monitor._watchers = [watcher]
monitor._isolated = {str(tmp_path): {"watcher": watcher, "since": time.time(), "failures": 3}}
rebuilt = []
setattr(monitor, "_Monitor__rebuild_watcher", rebuilt.append)
monkeypatch.setattr("app.monitor.monitor.probe_path", lambda *_a, **_kw: True)
assert _run_watchdog(monitor)
assert str(tmp_path) not in monitor._isolated, "探测通过后没有解除隔离"
# 不得在探测线程里内联重建:重建会触碰挂载,若此时再次挂死,全局 PROBE_KEY
# 将永久 BUSY,其余隔离目录连探测机会都没有
assert rebuilt == [], "探测线程内联执行了重建,会饿死其他隔离目录"
assert str(tmp_path) in monitor._pending_rebuild, "没有登记待重建"
# 下一周期应把它送去按目录隔离的重建路径
assert _run_watchdog(monitor)
assert rebuilt == [watcher], "下一周期没有重建该监控"
assert not monitor._pending_rebuild, "重建后未清空待重建登记"
def test_isolated_directory_stays_isolated_while_probe_fails(tmp_path, monkeypatch):
"""
探测未通过说明挂载仍未恢复应答,必须保持隔离并累计失败次数。
"""
monkeypatch.setattr(Monitor, "RECOVERY_TIMEOUT", 5.0)
monitor = _build_monitor(monkeypatch)
watcher = _fake_watcher(tmp_path, alive=True, stalled=True)
monitor._watchers = [watcher]
monitor._isolated = {str(tmp_path): {"watcher": watcher, "since": time.time(), "failures": 0}}
rebuilt = []
setattr(monitor, "_Monitor__rebuild_watcher", rebuilt.append)
monkeypatch.setattr("app.monitor.monitor.probe_path", lambda *_a, **_kw: False)
assert _run_watchdog(monitor)
assert monitor._isolated[str(tmp_path)]["failures"] == 1
assert rebuilt == [], "挂载尚未恢复就重建监控,只会再冻死一个线程"
def test_pending_locals_skip_isolated_directories(tmp_path, monkeypatch):
"""
隔离中的目录不能再走启动重试路径,否则又会在同一个挂载上冻死。
"""
monitor = _build_monitor(monkeypatch)
monitor._pending_locals = [{"mon_path": tmp_path, "monitor_mode": "compatibility"}]
monitor._isolated = {str(tmp_path): {"watcher": _fake_watcher(tmp_path), "since": time.time(),
"failures": 0}}
start = MagicMock(return_value=False)
setattr(monitor, "_Monitor__start_local_monitor", start)
monitor._Monitor__retry_pending_locals()
start.assert_not_called()
def test_isolation_alert_is_pushed_after_fault_alert(tmp_path, monkeypatch):
"""
故障→隔离是状态升级,必须再推一条告警:沿用「同目录只告警一次」会让
用户完全看不到「监控已暂停访问、正在等待挂载恢复」这个关键状态变化。
"""
put_recorder = MagicMock()
monitor = _build_monitor(monkeypatch, put_recorder)
monitor._Monitor__send_alert(tmp_path, "目录监控异常")
monitor._Monitor__send_alert(tmp_path, "目录监控异常")
assert put_recorder.put.call_count == 1
monitor._Monitor__send_alert(tmp_path, "挂载无响应,已隔离", stage="isolated")
assert put_recorder.put.call_count == 2
monitor._Monitor__clear_alert(tmp_path, "已恢复")
assert put_recorder.put.call_count == 3
assert str(tmp_path) not in monitor._alerted_paths
# --------------------------------------------------------------------------- #
# 整理分发器:一个文件卡死不得锁死整条整理链
# --------------------------------------------------------------------------- #
def test_stuck_transfer_does_not_block_other_files(monkeypatch):
"""
根因回归:整理的规划阶段(do_transfer 里的 get_parent_item / list_files)会
访问挂载,在 block 型故障下永不返回。若分发器的互斥锁包住这段调用,这把锁
就会被永久持有,把 13 个 watcher 的事件派发、补偿扫描和重试队列一起锁死
——监控层即使自愈成功也送不进任何文件,漏件永远补不回来。
锁只应保护 TTL 去重的 check-and-set。
"""
from app.monitor.dispatcher import TransferDispatcher
dispatcher = TransferDispatcher(all_exts=[".mkv"], cache={})
monkeypatch.setattr(dispatcher, "_should_skip_by_history", MagicMock(return_value=False))
release = threading.Event()
stuck_entered = threading.Event()
transferred = []
class FakeChain:
"""
整理链替身:指定文件的整理进入后永不返回。
"""
@staticmethod
def do_transfer(fileitem, **_kwargs):
"""
模拟规划阶段卡在死挂载的 list_files 上。
"""
transferred.append(fileitem.path)
if fileitem.path.endswith("stuck.mkv"):
stuck_entered.set()
release.wait()
monkeypatch.setattr("app.monitor.dispatcher.TransferChain", FakeChain)
def feed(name):
"""
向分发器送入一个文件。
"""
dispatcher.handle_file(storage="local", event_path=Path(f"/mnt/cd2/{name}"), file_size=1)
stuck_thread = Thread(target=feed, args=("stuck.mkv",), daemon=True)
stuck_thread.start()
assert stuck_entered.wait(timeout=5), "卡死的整理没有真正进入"
done = threading.Event()
other_thread = Thread(target=lambda: (feed("other.mkv"), done.set()), daemon=True)
other_thread.start()
try:
assert done.wait(timeout=5), "一个文件卡在死挂载上就锁死了整条整理链"
assert "/mnt/cd2/other.mkv" in transferred
finally:
_release_and_join(release, (stuck_thread, other_thread))
# --------------------------------------------------------------------------- #
# 恢复执行器与挂载探测
# --------------------------------------------------------------------------- #
def test_recovery_executor_reports_timeout_without_blocking():
"""
永不返回的动作只应消耗一次 timeout,执行器必须放弃它并如实报告。
"""
executor = RecoveryExecutor()
release = threading.Event()
started = time.monotonic()
results = executor.run({"stuck": release.wait}, timeout=0.3)
elapsed = time.monotonic() - started
try:
assert results == {"stuck": RecoveryState.TIMEOUT}
assert elapsed < 3.0, "执行器没有在超时后放弃冻死的动作"
finally:
_release_and_join(release, executor._running.values())
def test_recovery_executor_skips_key_with_running_task():
"""
同一个 key 的上一个动作还冻着时,不能再提交新线程——否则每个健康检查
周期都会在同一个死挂载上泄漏一个线程。
"""
executor = RecoveryExecutor()
release = threading.Event()
calls = []
def stuck():
"""
模拟永不返回的恢复动作。
"""
calls.append(1)
release.wait()
try:
assert executor.run({"k": stuck}, timeout=0.2) == {"k": RecoveryState.TIMEOUT}
assert executor.run({"k": stuck}, timeout=0.2) == {"k": RecoveryState.BUSY}
assert len(calls) == 1
finally:
_release_and_join(release, executor._running.values())
def test_recovery_executor_runs_actions_concurrently():
"""
一个周期内多个目录的恢复动作必须并发执行,否则 13 个目录串行等待会把
健康检查拖过下一个周期。
"""
executor = RecoveryExecutor()
release = threading.Event()
started = time.monotonic()
results = executor.run({f"k{i}": release.wait for i in range(5)}, timeout=0.4)
elapsed = time.monotonic() - started
try:
assert set(results.values()) == {RecoveryState.TIMEOUT}
assert elapsed < 1.5, "恢复动作是串行等待的,总耗时随目录数增长"
finally:
_release_and_join(release, executor._running.values())
def test_recovery_executor_reports_completion_and_swallows_errors():
"""
正常完成的动作报告 COMPLETED;动作内部抛异常不能让执行器崩溃,
否则一个目录的失败会连累整批恢复。
"""
executor = RecoveryExecutor()
done = []
def boom():
"""
模拟恢复动作内部异常。
"""
raise RuntimeError("rebuild failed")
results = executor.run({"ok": lambda: done.append(1), "bad": boom}, timeout=5)
assert results == {"ok": RecoveryState.COMPLETED, "bad": RecoveryState.COMPLETED}
assert done == [1]
def test_probe_path_succeeds_on_existing_directory(tmp_path):
"""
挂载可用时探测应通过。
"""
assert probe_path(tmp_path, timeout=30) is True
def test_probe_path_fails_on_missing_path(tmp_path):
"""
路径不存在时探测应失败,而不是抛异常。
"""
assert probe_path(tmp_path / "does-not-exist", timeout=30) is False
@pytest.mark.skipif(not Path("/bin/sh").exists(), reason="需要 POSIX 环境")
def test_probe_path_is_abandonable_on_timeout(tmp_path, monkeypatch):
"""
探测的关键性质:卡住时可以被放弃。线程做的 stat 无法回收,只有子进程能在
超时后被 kill——这正是隔离期间能持续探测而不泄漏资源的前提。
"""
import app.monitor.recovery as recovery
# 用一个必定超时的探测脚本替换真实探测逻辑,验证超时路径的可放弃性
monkeypatch.setattr(recovery, "_PROBE_SCRIPT", "import time; time.sleep(60)")
started = time.monotonic()
assert probe_path(tmp_path, timeout=0.5) is False
assert time.monotonic() - started < 10, "探测超时后没有及时放弃子进程"