Files
MoviePilot/tests/test_module_lifecycle.py
T
InfinityPacerandjxxghp be7dfd77a3 fix(runtime): 收敛模块关闭线程所有权 (#6443)
* fix(runtime): bound module shutdown owners

* fix(runtime): declare blocking shutdown owners

---------

Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-08-25 06:58:31 +08:00

423 lines
14 KiB
Python

import asyncio
import threading
import time
from unittest.mock import Mock, patch
import pytest
from telebot import TeleBot
from app.modules import _MessageBase
from app.modules.discord import DiscordModule
from app.modules.discord.discord import Discord
from app.modules.feishu import FeishuModule
from app.modules.feishu.feishu import Feishu
from app.modules.filter import FilterModule
from app.modules.plex import PlexModule
from app.modules.qqbot.module import QQBotModule
from app.modules.qqbot.qqbot import QQBot
from app.modules.slack import SlackModule
from app.modules.slack.slack import Slack
from app.modules.telegram.module import TelegramModule
from app.modules.telegram.telegram import Telegram
from app.modules.themoviedb import TheMovieDbModule
from app.modules.trimemedia.module import TrimeMediaModule
from app.modules.ugreen.module import UgreenModule
from app.modules.wechat import WechatModule
from app.modules.wechat.wechatbot import WeChatBot
from app.modules.wechatclawbot import WechatClawBotModule
from app.modules.wechatclawbot.wechatclawbot import WechatClawBot
from app.runtime.execution import run_in_threadpool_to_completion
def test_config_reload_stops_before_initializing_latest_generation():
"""同一模块的重载必须串行,并依次停止和初始化 generation。"""
module = TelegramModule()
call_order = []
reload_started = threading.Event()
reload_finished = threading.Event()
def reload_module():
reload_started.set()
module.on_config_changed()
reload_finished.set()
with patch.object(
module, "stop", side_effect=lambda: call_order.append("stop")
), patch.object(
_MessageBase,
"init_service",
side_effect=lambda **_kwargs: call_order.append("init"),
):
module._reload_lock.acquire()
try:
reload_thread = threading.Thread(target=reload_module)
reload_thread.start()
assert reload_started.wait(1)
assert not reload_finished.wait(0.1)
finally:
module._reload_lock.release()
assert reload_finished.wait(1)
reload_thread.join()
assert call_order == ["stop", "init"]
def test_initialization_does_not_stop_a_fresh_module_generation():
"""首次初始化只创建资源,停止旧 generation 由重载入口负责。"""
module = TelegramModule()
with patch.object(module, "stop") as stop, patch.object(
_MessageBase, "init_service"
) as init_service:
module.init_module()
stop.assert_not_called()
init_service.assert_called_once()
def test_config_reload_initializes_latest_generation_after_stop_failure():
"""旧资源停止异常只记录错误,不阻止最新配置完成初始化。"""
module = TelegramModule()
call_order = []
def stop_with_failure():
call_order.append("stop")
raise RuntimeError("stop failed")
with patch.object(
module,
"stop",
side_effect=stop_with_failure,
), patch.object(
_MessageBase,
"init_service",
side_effect=lambda **_kwargs: call_order.append("init"),
):
module.on_config_changed()
assert call_order == ["stop", "init"]
def test_tmdb_reload_closes_old_client_when_cache_save_fails():
"""TMDB 缓存保存失败时仍须关闭旧客户端并初始化最新配置。"""
module = TheMovieDbModule()
module.cache = Mock()
module.cache.save.side_effect = OSError("cache write failed")
module.tmdb = Mock()
with patch.object(module, "init_module") as init_module:
module.on_config_changed()
module.tmdb.close.assert_called_once_with()
init_module.assert_called_once_with()
def test_filter_reload_uses_shared_module_lifecycle_lock():
"""过滤规则重载必须经过模块基类的串行 stop 和 init。"""
module = FilterModule()
reload_started = threading.Event()
reload_finished = threading.Event()
call_order = []
def reload_module():
reload_started.set()
module.on_config_changed()
reload_finished.set()
with patch(
"app.modules.filter.clear_rust_parse_options_cache",
side_effect=lambda: call_order.append("stop"),
), patch.object(
module, "init_module", side_effect=lambda: call_order.append("init")
):
module._reload_lock.acquire()
try:
reload_thread = threading.Thread(target=reload_module)
reload_thread.start()
assert reload_started.wait(1)
assert not reload_finished.wait(0.1)
finally:
module._reload_lock.release()
assert reload_finished.wait(1)
reload_thread.join()
assert call_order == ["stop", "init"]
@pytest.mark.parametrize(
("module_type", "stop_method", "requires_authentication"),
[
(DiscordModule, "stop", False),
(FeishuModule, "stop", False),
(QQBotModule, "stop", False),
(SlackModule, "stop", False),
(TelegramModule, "stop", False),
(WechatModule, "stop", False),
(WechatClawBotModule, "stop", False),
(PlexModule, "close", False),
(TrimeMediaModule, "disconnect", True),
(UgreenModule, "disconnect", True),
],
)
def test_module_stop_isolates_each_service_instance(
module_type, stop_method, requires_authentication
):
"""单个服务停止失败时必须继续关闭同模块的其余实例。"""
module = module_type()
failed_client = Mock()
healthy_client = Mock()
getattr(failed_client, stop_method).side_effect = RuntimeError("stop failed")
if requires_authentication:
failed_client.is_authenticated.return_value = True
healthy_client.is_authenticated.return_value = True
module._instances = {"failed": failed_client, "healthy": healthy_client}
module.stop()
getattr(failed_client, stop_method).assert_called_once_with()
getattr(healthy_client, stop_method).assert_called_once_with()
def test_telegram_stop_closes_sdk_and_waits_for_polling_thread():
"""客户端停止完成后不得保留 SDK worker 或 polling 线程句柄。"""
client = Telegram.__new__(Telegram)
bot = Mock()
bot.threaded = False
bot.worker_pool = None
client._bot = bot
polling_thread = Mock()
polling_thread.is_alive.side_effect = [True, False]
client._polling_thread = polling_thread
client._typing_tasks = {}
client._typing_stop_flags = {}
client._typing_lock = threading.RLock()
client._typing_lifecycle_lock = threading.RLock()
client._typing_accepting = True
assert client.stop() is True
assert client.stop() is True
bot.stop_polling.assert_called_once_with()
polling_thread.join.assert_called_once_with(
timeout=pytest.approx(client._shutdown_timeout_seconds, abs=0.1)
)
assert client._bot is None
assert client._polling_thread is None
def test_telegram_stop_keeps_polling_owner_when_thread_misses_deadline():
"""polling 超过关闭预算时必须返回未收敛并保留原 owner。"""
client = Telegram.__new__(Telegram)
bot = Mock()
bot.threaded = False
bot.worker_pool = None
polling_thread = Mock()
polling_thread.is_alive.return_value = True
client._bot = bot
client._polling_thread = polling_thread
client._shutdown_timeout_seconds = 0.01
client._typing_tasks = {}
client._typing_stop_flags = {}
client._typing_lock = threading.RLock()
client._typing_lifecycle_lock = threading.RLock()
client._typing_accepting = True
assert client.stop() is False
polling_thread.join.assert_called_once()
remaining_timeout = polling_thread.join.call_args.kwargs["timeout"]
assert 0 <= remaining_timeout <= client._shutdown_timeout_seconds
assert client._bot is bot
assert client._polling_thread is polling_thread
@pytest.mark.asyncio
async def test_telegram_stop_bounds_real_sdk_worker_and_retries_after_release():
"""真实 SDK worker 阻塞时应保留 owner,释放后重试可以完整收敛。"""
bot = TeleBot("123:test", threaded=True, num_threads=1)
entered = threading.Event()
release = threading.Event()
def blocking_callback() -> None:
entered.set()
release.wait(timeout=1.0)
bot.worker_pool.put(blocking_callback)
assert await asyncio.to_thread(entered.wait, 0.2)
client = Telegram.__new__(Telegram)
client._bot = bot
client._polling_thread = None
client._shutdown_timeout_seconds = 0.02
client._typing_tasks = {}
client._typing_stop_flags = {}
client._typing_lock = threading.RLock()
client._typing_lifecycle_lock = threading.RLock()
client._typing_accepting = True
heartbeat = asyncio.create_task(asyncio.sleep(0.005))
started_at = time.monotonic()
try:
assert await run_in_threadpool_to_completion(client.stop) is False
assert time.monotonic() - started_at < 0.2
assert heartbeat.done()
assert client._bot is bot
assert any(worker.is_alive() for worker in bot.worker_pool.workers)
release.set()
for worker in bot.worker_pool.workers:
await asyncio.to_thread(worker.join, 0.2)
assert await run_in_threadpool_to_completion(client.stop) is True
assert client._bot is None
assert client._polling_thread is None
finally:
release.set()
for worker in bot.worker_pool.workers:
worker.stop()
await asyncio.to_thread(worker.join, 0.2)
@pytest.mark.parametrize(
"module_type",
[
DiscordModule,
FeishuModule,
QQBotModule,
SlackModule,
TelegramModule,
WechatModule,
WechatClawBotModule,
],
)
def test_message_channel_module_reports_nonconverging_instance_after_peers(
module_type,
):
"""渠道单实例未收敛时必须继续停止其余实例并返回 False。"""
module = module_type()
blocked_client = Mock()
blocked_client.stop.return_value = False
healthy_client = Mock()
healthy_client.stop.return_value = True
module._instances = {
"blocked": blocked_client,
"healthy": healthy_client,
}
assert module.stop() is False
blocked_client.stop.assert_called_once_with()
healthy_client.stop.assert_called_once_with()
def test_qqbot_stop_retains_gateway_thread_until_retry() -> None:
"""QQ Gateway 超时后不得把活线程伪装成已收敛。"""
client = QQBot.__new__(QQBot)
client._gateway_stop = threading.Event()
client._gateway_ws_holder = []
gateway_thread = Mock()
gateway_thread.is_alive.return_value = True
client._gateway_thread = gateway_thread
client._gateway_join_timeout_seconds = 0.01
assert client.stop() is False
assert client._gateway_thread is gateway_thread
gateway_thread.is_alive.return_value = False
assert client.stop() is True
def test_wechat_bot_stop_reports_each_live_thread_until_retry() -> None:
"""企业微信网关或心跳任一存活时都必须返回未收敛。"""
client = WeChatBot.__new__(WeChatBot)
client._stop_event = threading.Event()
client._authenticated = threading.Event()
client._ws_app = None
ws_thread = Mock()
heartbeat_thread = Mock()
ws_thread.is_alive.return_value = True
heartbeat_thread.is_alive.return_value = True
client._ws_thread = ws_thread
client._heartbeat_thread = heartbeat_thread
client._gateway_join_timeout_seconds = 0.01
client._heartbeat_join_timeout_seconds = 0.01
assert client.stop() is False
assert client._ws_thread is ws_thread
assert client._heartbeat_thread is heartbeat_thread
ws_thread.is_alive.return_value = False
heartbeat_thread.is_alive.return_value = False
assert client.stop() is True
def test_wechat_clawbot_stop_keeps_poll_owner_until_retry() -> None:
"""ClawBot 轮询超时后必须保留线程句柄供后续关闭重试。"""
client = WechatClawBot.__new__(WechatClawBot)
client._stop_event = threading.Event()
poll_thread = Mock()
poll_thread.is_alive.return_value = True
client._poll_thread = poll_thread
client._poll_join_timeout_seconds = 0.01
assert client.stop() is False
assert client._poll_thread is poll_thread
poll_thread.is_alive.return_value = False
assert client.stop() is True
assert client._poll_thread is None
def test_feishu_stop_reports_live_ws_thread_until_retry() -> None:
"""飞书 SDK 清理后线程仍存活时不得报告关闭完成。"""
client = Feishu.__new__(Feishu)
client._stop_event = threading.Event()
client._ready = threading.Event()
client._ws_client = None
client._ws_loop = None
ws_thread = Mock()
ws_thread.is_alive.return_value = True
client._ws_thread = ws_thread
client._ws_join_timeout_seconds = 0.01
assert client.stop() is False
assert client._ws_thread is ws_thread
ws_thread.is_alive.return_value = False
assert client.stop() is True
def test_discord_stop_reports_live_event_loop_thread_until_retry() -> None:
"""Discord 强制停止循环后线程仍存活时必须返回未收敛。"""
client = Discord.__new__(Discord)
client._client = Mock()
client._loop = Mock()
client._loop.is_running.return_value = False
event_loop_thread = Mock()
event_loop_thread.is_alive.return_value = True
client._thread = event_loop_thread
client._stop_requested = threading.Event()
client._ready_event = threading.Event()
client._thread_join_timeout_seconds = 0.01
assert client.stop() is False
assert client._thread is event_loop_thread
event_loop_thread.is_alive.return_value = False
assert client.stop() is True
def test_slack_stop_propagates_socket_close_failure() -> None:
"""Slack Socket Mode close 失败必须保留给 Runtime 重试。"""
client = Slack.__new__(Slack)
service = Mock()
service.close.side_effect = RuntimeError("close failed")
client._service = service
assert client.stop() is False
assert client._service is service
service.close.side_effect = None
assert client.stop() is True