mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-04 22:31:28 +08:00
fix(tests): stabilize messaging shutdown (#5979)
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
引导与网络守卫均复用 ``app/testing`` 的共享 harness(与插件仓 conftest 同源),
|
||||
引导逻辑只在 ``app/testing`` 维护一处。
|
||||
"""
|
||||
import sys
|
||||
|
||||
# 必须早于首个 import app.db(其在 import 期即按 CONFIG_PATH 连库):prepare_backend 内部
|
||||
# 先隔离 CONFIG_DIR、补 app.helper.sites 垫片,再建表。app/testing 仅依赖标准库、import 不连库,
|
||||
# 故此处先 import 再调用是安全的。
|
||||
@@ -12,3 +14,35 @@ prepare_backend()
|
||||
|
||||
# 复用共享 autouse 网络守卫;同一实现亦供各插件仓 conftest import 复用,避免逐仓维护
|
||||
from app.testing.network_guard import block_real_network # noqa: E402,F401
|
||||
|
||||
|
||||
def _report_session_cleanup_error(name: str, err: Exception) -> None:
|
||||
"""测试收尾清理失败只记录诊断,不覆盖原始 pytest 退出状态。"""
|
||||
sys.stderr.write(f"\npytest session cleanup failed: {name}: {err!r}\n")
|
||||
|
||||
|
||||
def pytest_sessionfinish(session, exitstatus):
|
||||
"""释放测试过程中按需创建的全局后台资源,避免解释器退出时等待非 daemon worker。"""
|
||||
try:
|
||||
from app.agent.tools.base import shutdown_blocking_executors
|
||||
|
||||
shutdown_blocking_executors(cancel_futures=True)
|
||||
except Exception as err:
|
||||
_report_session_cleanup_error("agent blocking executors", err)
|
||||
|
||||
try:
|
||||
from app.helper.thread import ThreadHelper
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
helper = Singleton._instances.get((ThreadHelper, (), frozenset()))
|
||||
if helper:
|
||||
helper.shutdown()
|
||||
except Exception as err:
|
||||
_report_session_cleanup_error("thread helper", err)
|
||||
|
||||
try:
|
||||
from app.log import LoggerManager
|
||||
|
||||
LoggerManager.shutdown()
|
||||
except Exception as err:
|
||||
_report_session_cleanup_error("logger manager", err)
|
||||
|
||||
@@ -4,7 +4,7 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.base import MoviePilotTool, _blocking_executors, shutdown_blocking_executors
|
||||
from app.agent.tools.manager import MoviePilotToolsManager
|
||||
|
||||
|
||||
@@ -96,6 +96,55 @@ def test_run_blocking_keeps_bucket_slot_until_worker_finishes():
|
||||
asyncio.run(_run_scenario())
|
||||
|
||||
|
||||
def test_shutdown_blocking_executors_clears_agent_tool_workers():
|
||||
"""测试结束清理应关闭 Agent 工具阻塞线程池,避免全量测试退出时等待 worker。"""
|
||||
|
||||
async def _create_worker():
|
||||
await MoviePilotTool.run_blocking("web", lambda: "done")
|
||||
|
||||
asyncio.run(_create_worker())
|
||||
assert "web" in _blocking_executors
|
||||
|
||||
shutdown_blocking_executors()
|
||||
|
||||
assert _blocking_executors == {}
|
||||
|
||||
|
||||
def test_shutdown_blocking_executors_cancels_queued_workers_and_is_idempotent():
|
||||
"""收尾清理应取消尚未开始的排队任务,并允许重复调用。"""
|
||||
shutdown_blocking_executors(wait=False, cancel_futures=True)
|
||||
started = [threading.Event(), threading.Event()]
|
||||
release = threading.Event()
|
||||
queued_ran = threading.Event()
|
||||
|
||||
def _blocking_call(index: int) -> str:
|
||||
started[index].set()
|
||||
release.wait()
|
||||
return f"done-{index}"
|
||||
|
||||
async def _run_scenario():
|
||||
tasks = [
|
||||
asyncio.create_task(MoviePilotTool.run_blocking("web", _blocking_call, index))
|
||||
for index in range(2)
|
||||
]
|
||||
for event in started:
|
||||
assert await asyncio.wait_for(asyncio.to_thread(event.wait), timeout=1)
|
||||
executor = _blocking_executors["web"]
|
||||
queued_future = executor.submit(queued_ran.set)
|
||||
shutdown_blocking_executors(wait=False, cancel_futures=True)
|
||||
shutdown_blocking_executors(wait=False, cancel_futures=True)
|
||||
release.set()
|
||||
|
||||
assert await asyncio.wait_for(asyncio.gather(*tasks), timeout=1) == ["done-0", "done-1"]
|
||||
return queued_future
|
||||
|
||||
queued_future = asyncio.run(_run_scenario())
|
||||
|
||||
assert _blocking_executors == {}
|
||||
assert queued_future.cancelled()
|
||||
assert not queued_ran.is_set()
|
||||
|
||||
|
||||
def test_create_agent_config_uses_llm_max_iterations():
|
||||
"""Agent 执行配置应把 LLM_MAX_ITERATIONS 传给 LangGraph recursion_limit。"""
|
||||
from app.agent import MoviePilotAgent
|
||||
|
||||
@@ -1158,15 +1158,30 @@ class TestFeishu(unittest.TestCase):
|
||||
def test_run_ws_client_binds_thread_local_event_loop(self):
|
||||
client = self._build_client()
|
||||
original_loop = object()
|
||||
fake_ws_client = MagicMock()
|
||||
created_loops = []
|
||||
real_new_event_loop = asyncio.new_event_loop
|
||||
|
||||
class _FakeWsClient:
|
||||
"""显式模拟飞书 SDK 长连接客户端,避免 MagicMock 在线程清理路径污染全局 mock 锁。"""
|
||||
|
||||
def __init__(self):
|
||||
self._auto_reconnect = True
|
||||
self._conn = None
|
||||
self._conn_url = "wss://msg-frontier.feishu.cn/ws/v2?access_key=secret&ticket=secret"
|
||||
self._conn_id = "conn_test"
|
||||
self._service_id = "service_test"
|
||||
self._lock = asyncio.Lock()
|
||||
self.started = False
|
||||
|
||||
def start(self):
|
||||
self.started = True
|
||||
|
||||
def _new_loop():
|
||||
loop = real_new_event_loop()
|
||||
created_loops.append(loop)
|
||||
return loop
|
||||
|
||||
fake_ws_client = _FakeWsClient()
|
||||
with (
|
||||
patch(
|
||||
"app.modules.feishu.feishu.lark_ws_client_module.loop", original_loop
|
||||
@@ -1182,14 +1197,11 @@ class TestFeishu(unittest.TestCase):
|
||||
patch(
|
||||
"app.modules.feishu.feishu.lark.ws.Client", return_value=fake_ws_client
|
||||
),
|
||||
patch.object(
|
||||
fake_ws_client, "start", side_effect=lambda: None
|
||||
) as mock_start,
|
||||
):
|
||||
client._run_ws_client()
|
||||
|
||||
self.assertIsNone(client._ws_loop)
|
||||
mock_start.assert_called_once()
|
||||
self.assertTrue(fake_ws_client.started)
|
||||
self.assertEqual(len(created_loops), 1)
|
||||
self.assertTrue(created_loops[0].is_closed())
|
||||
|
||||
|
||||
@@ -71,6 +71,35 @@ def test_shutdown_ws_client_cancels_sdk_tasks_before_quiet_disconnect():
|
||||
assert client._ws_tasks == set()
|
||||
|
||||
|
||||
def test_shutdown_ws_client_skips_disconnect_when_sdk_lock_is_busy_and_connection_gone():
|
||||
"""SDK 已无连接对象时,关机清理不应等待可能长期占用的内部锁。"""
|
||||
client = _build_feishu_client()
|
||||
|
||||
async def _run_shutdown() -> SimpleNamespace:
|
||||
lock = asyncio.Lock()
|
||||
await lock.acquire()
|
||||
ws_client = SimpleNamespace(
|
||||
_auto_reconnect=True,
|
||||
_conn=None,
|
||||
_conn_url="wss://msg-frontier.feishu.cn/ws/v2?access_key=secret&ticket=secret",
|
||||
_conn_id="conn_test",
|
||||
_service_id="service_test",
|
||||
_lock=lock,
|
||||
)
|
||||
client._ws_client = ws_client
|
||||
|
||||
await asyncio.wait_for(client._shutdown_ws_client(), timeout=0.2)
|
||||
return ws_client
|
||||
|
||||
ws_client = asyncio.run(_run_shutdown())
|
||||
|
||||
assert not ws_client._auto_reconnect
|
||||
assert ws_client._conn is None
|
||||
assert ws_client._conn_url == ""
|
||||
assert ws_client._conn_id == ""
|
||||
assert ws_client._service_id == ""
|
||||
|
||||
|
||||
def test_consume_ws_task_result_suppresses_stop_exception():
|
||||
"""停止过程中飞书 SDK 后台任务的异常应被取回并降为调试日志。"""
|
||||
client = _build_feishu_client()
|
||||
|
||||
@@ -25,9 +25,14 @@ def telegram():
|
||||
bot_instance = MagicMock()
|
||||
# get_me 用于初始化 bot 用户名,需返回带 username 的对象
|
||||
bot_instance.get_me.return_value = MagicMock(username="test_bot")
|
||||
# polling/stop 使用普通函数,避免后台线程执行 MagicMock 时在退出阶段产生锁竞争。
|
||||
bot_instance.infinity_polling = lambda *args, **kwargs: None
|
||||
bot_instance.stop_polling = lambda *args, **kwargs: None
|
||||
mock_telebot_cls.return_value = bot_instance
|
||||
mock_image_cls.return_value.fetch_image.return_value = b"fake-image-bytes"
|
||||
yield Telegram(TELEGRAM_TOKEN="fake_token", TELEGRAM_CHAT_ID="fake_chat_id")
|
||||
telegram = Telegram(TELEGRAM_TOKEN="fake_token", TELEGRAM_CHAT_ID="fake_chat_id")
|
||||
yield telegram
|
||||
telegram.stop()
|
||||
|
||||
|
||||
def test_send_msg_success(telegram):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
@@ -13,6 +14,28 @@ from app.modules.telegram.telegram import Telegram
|
||||
from app.schemas.types import MessageChannel
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout: float = 1.0) -> bool:
|
||||
"""等待后台线程完成目标状态,避免用例依赖固定 sleep 时长。"""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.01)
|
||||
return predicate()
|
||||
|
||||
|
||||
class _FakeTelegramBot:
|
||||
"""记录 typing 调用的轻量 bot,避免后台线程与 Mock 内部锁交互。"""
|
||||
|
||||
def __init__(self):
|
||||
self.chat_actions = []
|
||||
self.action_event = threading.Event()
|
||||
|
||||
def send_chat_action(self, chat_id, action):
|
||||
self.chat_actions.append((chat_id, action))
|
||||
self.action_event.set()
|
||||
|
||||
|
||||
class TestTelegramTypingLifecycle(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._cleanup_typing_tasks()
|
||||
@@ -32,7 +55,7 @@ class TestTelegramTypingLifecycle(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _telegram_client() -> Telegram:
|
||||
telegram = Telegram.__new__(Telegram)
|
||||
telegram._bot = Mock()
|
||||
telegram._bot = _FakeTelegramBot()
|
||||
telegram._telegram_token = "token"
|
||||
telegram._telegram_chat_id = "default-chat"
|
||||
# 缩短测试中的等待时间,不改变生产默认续发间隔。
|
||||
@@ -48,10 +71,9 @@ class TestTelegramTypingLifecycle(unittest.TestCase):
|
||||
max_duration_seconds=1,
|
||||
initial_delay_seconds=0,
|
||||
)
|
||||
time.sleep(0.03)
|
||||
|
||||
self.assertIn("chat-1", Telegram._typing_tasks)
|
||||
self.assertTrue(telegram._bot.send_chat_action.called)
|
||||
self.assertTrue(telegram._bot.action_event.wait(1.0))
|
||||
self.assertTrue(telegram.stop_typing(chat_id="chat-1"))
|
||||
self.assertNotIn("chat-1", Telegram._typing_tasks)
|
||||
|
||||
@@ -77,8 +99,8 @@ class TestTelegramTypingLifecycle(unittest.TestCase):
|
||||
max_duration_seconds=0.02,
|
||||
initial_delay_seconds=0,
|
||||
)
|
||||
time.sleep(0.08)
|
||||
|
||||
self.assertTrue(_wait_until(lambda: "chat-3" not in Telegram._typing_tasks))
|
||||
self.assertNotIn("chat-3", Telegram._typing_tasks)
|
||||
|
||||
def test_short_typing_task_can_stop_before_first_chat_action(self):
|
||||
@@ -95,7 +117,7 @@ class TestTelegramTypingLifecycle(unittest.TestCase):
|
||||
telegram.stop_typing(chat_id="chat-4")
|
||||
time.sleep(0.08)
|
||||
|
||||
telegram._bot.send_chat_action.assert_not_called()
|
||||
self.assertEqual(telegram._bot.chat_actions, [])
|
||||
self.assertNotIn("chat-4", Telegram._typing_tasks)
|
||||
|
||||
def test_agent_managed_send_msg_keeps_typing_for_worker_cleanup(self):
|
||||
@@ -319,19 +341,24 @@ class TestTelegramTypingLifecycle(unittest.TestCase):
|
||||
"chat_id": "-100",
|
||||
"metadata": {"kind": "typing"},
|
||||
}
|
||||
calls = []
|
||||
|
||||
with patch("app.agent.AgentChain") as chain_cls:
|
||||
chain_cls.return_value.start_message_processing_status.return_value = status
|
||||
class FakeAgentChain:
|
||||
def start_message_processing_status(self, **kwargs):
|
||||
calls.append(kwargs)
|
||||
return status
|
||||
|
||||
with patch("app.agent.AgentChain", FakeAgentChain):
|
||||
result = await _async_start_processing_status(task)
|
||||
|
||||
chain_cls.return_value.start_message_processing_status.assert_called_once_with(
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
message_id="10",
|
||||
chat_id="-100",
|
||||
text="第一条",
|
||||
)
|
||||
self.assertEqual(calls, [{
|
||||
"channel": MessageChannel.Telegram,
|
||||
"source": "telegram-test",
|
||||
"userid": "10001",
|
||||
"message_id": "10",
|
||||
"chat_id": "-100",
|
||||
"text": "第一条",
|
||||
}])
|
||||
self.assertEqual(result, status)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
Reference in New Issue
Block a user