mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
fix(runtime): enforce main event loop ownership (#6424)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
from dataclasses import replace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -7,7 +7,7 @@ from app.agent import MoviePilotAgent
|
||||
from app.agent.llm import AgentCapabilityManager, LLMHelper
|
||||
from app.agent.llm.provider import LLMProviderManager
|
||||
from app.chain.message import MessageChain
|
||||
from app.runtime.config import settings
|
||||
from app.runtime.config import global_vars, settings
|
||||
from app.schemas.types import NotificationChannel
|
||||
|
||||
|
||||
@@ -89,6 +89,8 @@ def test_handle_ai_message_routes_text_only_model_images_to_files(
|
||||
monkeypatch.setattr(settings, "LLM_SUPPORT_IMAGE_INPUT", True)
|
||||
monkeypatch.setattr(settings, "LLM_PROVIDER", "minimax")
|
||||
monkeypatch.setattr(settings, "LLM_MODEL", "MiniMax-M2.7")
|
||||
loop = Mock(**{"is_running.return_value": True, "is_closed.return_value": False})
|
||||
monkeypatch.setattr(global_vars, "CURRENT_EVENT_LOOP", loop)
|
||||
# 测试绕过完整启动组合根,按需装配 llm_helper provider 以走真实能力判断
|
||||
import app.application.agent as agent_facade
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from app.agent.tools.impl.send_local_file import SendLocalFileInput
|
||||
from app.agent import MoviePilotAgent, AgentChain
|
||||
from app.agent.llm import AgentCapabilityManager
|
||||
from app.chain.message import MessageChain
|
||||
from app.runtime.config import settings
|
||||
from app.runtime.config import global_vars, settings
|
||||
from app.agent.llm import LLMHelper
|
||||
from app.modules.discord import DiscordModule
|
||||
from app.modules.qqbot import QQBotModule
|
||||
@@ -454,7 +454,10 @@ class AgentImageSupportTest(unittest.TestCase):
|
||||
ai_agent_enable=True,
|
||||
)
|
||||
|
||||
with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object(
|
||||
loop = Mock(**{"is_running.return_value": True, "is_closed.return_value": False})
|
||||
with patch.object(global_vars, "CURRENT_EVENT_LOOP", loop), patch.object(
|
||||
settings, "AI_AGENT_ENABLE", True
|
||||
), patch.object(
|
||||
settings, "LLM_SUPPORT_IMAGE_INPUT", False
|
||||
), patch(
|
||||
"app.chain.message.supports_image_input", return_value=False
|
||||
@@ -508,7 +511,10 @@ class AgentImageSupportTest(unittest.TestCase):
|
||||
ai_agent_enable=True,
|
||||
)
|
||||
|
||||
with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object(
|
||||
loop = Mock(**{"is_running.return_value": True, "is_closed.return_value": False})
|
||||
with patch.object(global_vars, "CURRENT_EVENT_LOOP", loop), patch.object(
|
||||
settings, "AI_AGENT_ENABLE", True
|
||||
), patch.object(
|
||||
chain, "_get_or_create_session_id", return_value="session-1"
|
||||
), patch(
|
||||
"app.chain.message.get_running_agent_manager"
|
||||
|
||||
@@ -17,7 +17,7 @@ from app.application.messaging.agent import (
|
||||
)
|
||||
from app.application.messaging.interaction import InteractionContext
|
||||
from app.chain.message import MessageChain
|
||||
from app.runtime.config import settings
|
||||
from app.runtime.config import global_vars, settings
|
||||
from app.schemas.types import NotificationChannel
|
||||
|
||||
|
||||
@@ -193,7 +193,10 @@ class TestAgentInteraction(unittest.TestCase):
|
||||
],
|
||||
)
|
||||
|
||||
with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object(
|
||||
loop = Mock(**{"is_running.return_value": True, "is_closed.return_value": False})
|
||||
with patch.object(global_vars, "CURRENT_EVENT_LOOP", loop), patch.object(
|
||||
settings, "AI_AGENT_ENABLE", True
|
||||
), patch.object(
|
||||
chain.messagehelper, "put"
|
||||
) as message_put, patch.object(
|
||||
chain.messageoper, "add"
|
||||
|
||||
@@ -11,7 +11,7 @@ from app.agent.tools.impl.ask_user_choice import (
|
||||
)
|
||||
from app.agent.tools.impl.send_message import SendMessageTool
|
||||
from app.chain.message import MessageChain
|
||||
from app.runtime.config import settings
|
||||
from app.runtime.config import global_vars, settings
|
||||
from app.db import SessionFactory
|
||||
from app.db.oper.message import MessageOper
|
||||
from app.db.models.message import Message
|
||||
@@ -28,6 +28,13 @@ def _clear_messages() -> None:
|
||||
db.commit()
|
||||
|
||||
|
||||
def _running_loop_stub() -> Mock:
|
||||
"""提供满足主程序生命周期合同的事件循环替身。"""
|
||||
return Mock(
|
||||
**{"is_running.return_value": True, "is_closed.return_value": False}
|
||||
)
|
||||
|
||||
|
||||
def test_explicit_ai_message_bypasses_pending_media_interaction():
|
||||
"""显式 /ai 消息应绕过误触发的媒体交互状态并回到 Agent 会话。"""
|
||||
chain = MessageChain()
|
||||
@@ -69,7 +76,9 @@ def test_explicit_ai_message_is_not_recorded_to_message_history():
|
||||
chain.runtime_config = replace(chain.runtime_config, ai_agent_enable=True)
|
||||
manager = Mock(process_message=AsyncMock())
|
||||
|
||||
with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object(
|
||||
with patch.object(
|
||||
global_vars, "CURRENT_EVENT_LOOP", _running_loop_stub()
|
||||
), patch.object(settings, "AI_AGENT_ENABLE", True), patch.object(
|
||||
chain, "_record_user_message"
|
||||
) as record_user_message, patch(
|
||||
"app.chain.message.get_running_agent_manager", return_value=manager
|
||||
@@ -101,7 +110,11 @@ def test_agent_queue_full_is_reported_to_the_originating_channel():
|
||||
coro.close()
|
||||
return failed
|
||||
|
||||
with patch.object(settings, "AI_AGENT_ENABLE", True), patch(
|
||||
with patch.object(
|
||||
global_vars, "CURRENT_EVENT_LOOP", _running_loop_stub()
|
||||
), patch.object(
|
||||
settings, "AI_AGENT_ENABLE", True
|
||||
), patch(
|
||||
"app.chain.message.get_running_agent_manager", return_value=manager
|
||||
), patch(
|
||||
"app.chain.message.asyncio.run_coroutine_threadsafe",
|
||||
@@ -127,7 +140,9 @@ def test_message_chain_passes_stable_channel_admin_principal_to_agent():
|
||||
chain.runtime_config = replace(chain.runtime_config, ai_agent_enable=True)
|
||||
manager = Mock(process_message=AsyncMock())
|
||||
|
||||
with patch.object(settings, "AI_AGENT_ENABLE", True), patch(
|
||||
with patch.object(
|
||||
global_vars, "CURRENT_EVENT_LOOP", _running_loop_stub()
|
||||
), patch.object(settings, "AI_AGENT_ENABLE", True), patch(
|
||||
"app.chain.message.get_running_agent_manager", return_value=manager
|
||||
), patch(
|
||||
"app.chain.message.asyncio.run_coroutine_threadsafe",
|
||||
@@ -151,7 +166,9 @@ def test_message_chain_does_not_trust_channel_display_username():
|
||||
chain.runtime_config = replace(chain.runtime_config, ai_agent_enable=True)
|
||||
manager = Mock(process_message=AsyncMock())
|
||||
|
||||
with patch.object(settings, "AI_AGENT_ENABLE", True), patch(
|
||||
with patch.object(
|
||||
global_vars, "CURRENT_EVENT_LOOP", _running_loop_stub()
|
||||
), patch.object(settings, "AI_AGENT_ENABLE", True), patch(
|
||||
"app.chain.message.get_running_agent_manager", return_value=manager
|
||||
), patch(
|
||||
"app.chain.message.asyncio.run_coroutine_threadsafe",
|
||||
@@ -175,7 +192,9 @@ def test_message_chain_uses_same_admin_contract_for_slack():
|
||||
chain.runtime_config = replace(chain.runtime_config, ai_agent_enable=True)
|
||||
manager = Mock(process_message=AsyncMock())
|
||||
|
||||
with patch.object(settings, "AI_AGENT_ENABLE", True), patch(
|
||||
with patch.object(
|
||||
global_vars, "CURRENT_EVENT_LOOP", _running_loop_stub()
|
||||
), patch.object(settings, "AI_AGENT_ENABLE", True), patch(
|
||||
"app.chain.message.get_running_agent_manager", return_value=manager
|
||||
), patch(
|
||||
"app.chain.message.asyncio.run_coroutine_threadsafe",
|
||||
@@ -295,7 +314,9 @@ def test_agent_choice_callback_is_not_recorded_to_message_history():
|
||||
manager = Mock(process_message=AsyncMock())
|
||||
|
||||
try:
|
||||
with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object(
|
||||
with patch.object(
|
||||
global_vars, "CURRENT_EVENT_LOOP", _running_loop_stub()
|
||||
), patch.object(settings, "AI_AGENT_ENABLE", True), patch.object(
|
||||
chain, "_record_user_message"
|
||||
) as record_user_message, patch.object(
|
||||
chain, "edit_message", return_value=True
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app.runtime.config import GlobalVar
|
||||
|
||||
|
||||
def test_global_loop_requires_lifecycle_owner() -> None:
|
||||
"""启动前读取主循环不得隐式创建一个无法执行任务的循环。"""
|
||||
runtime = GlobalVar()
|
||||
runtime.CURRENT_EVENT_LOOP = None
|
||||
|
||||
with pytest.raises(RuntimeError, match="主事件循环尚未启动或已经停止"):
|
||||
_ = runtime.loop
|
||||
|
||||
assert runtime.CURRENT_EVENT_LOOP is None
|
||||
|
||||
|
||||
def test_global_loop_rejects_closed_owner() -> None:
|
||||
"""已关闭的生命周期 owner 不得继续接收跨线程任务。"""
|
||||
runtime = GlobalVar()
|
||||
loop = asyncio.new_event_loop()
|
||||
runtime.set_loop(loop)
|
||||
loop.close()
|
||||
|
||||
with pytest.raises(RuntimeError, match="主事件循环尚未启动或已经停止"):
|
||||
_ = runtime.loop
|
||||
|
||||
|
||||
def test_global_loop_rejects_owner_that_is_not_running() -> None:
|
||||
"""未运行的循环不得成为跨线程任务投递目标。"""
|
||||
runtime = GlobalVar()
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
runtime.set_loop(loop)
|
||||
|
||||
with pytest.raises(RuntimeError, match="主事件循环尚未启动或已经停止"):
|
||||
_ = runtime.loop
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
def test_clear_global_loop_preserves_new_owner() -> None:
|
||||
"""迟到的旧生命周期清理不得清除后来登记的循环。"""
|
||||
runtime = GlobalVar()
|
||||
previous = asyncio.new_event_loop()
|
||||
|
||||
async def verify() -> None:
|
||||
current = asyncio.get_running_loop()
|
||||
runtime.set_loop(current)
|
||||
runtime.clear_loop(previous)
|
||||
assert runtime.loop is current
|
||||
|
||||
runtime.clear_loop(current)
|
||||
assert runtime.CURRENT_EVENT_LOOP is None
|
||||
|
||||
try:
|
||||
asyncio.run(verify())
|
||||
finally:
|
||||
previous.close()
|
||||
@@ -21,6 +21,7 @@ def _patch_lifespan(monkeypatch, *, failing_step: str | None = None) -> dict:
|
||||
"""隔离 lifespan 的外部依赖,并按名称注入一个关闭失败"""
|
||||
monkeypatch.setattr(lifecycle.settings, "MOVIEPILOT_SAFE_MODE", False)
|
||||
monkeypatch.setattr(lifecycle.global_vars, "set_loop", MagicMock())
|
||||
monkeypatch.setattr(lifecycle.global_vars, "clear_loop", MagicMock())
|
||||
monkeypatch.setattr(lifecycle.global_vars, "stop_system", MagicMock())
|
||||
|
||||
for name in (
|
||||
@@ -134,6 +135,8 @@ def test_lifespan_normal_mode_starts_full_runtime(monkeypatch):
|
||||
|
||||
asyncio.run(run_lifespan())
|
||||
|
||||
configured_loop = lifecycle.global_vars.set_loop.call_args.args[0]
|
||||
lifecycle.global_vars.clear_loop.assert_called_once_with(configured_loop)
|
||||
lifecycle.init_modules.assert_awaited_once_with()
|
||||
lifecycle.prepare_database_component.assert_called_once()
|
||||
lifecycle.configure_plugin_services.assert_called_once_with()
|
||||
@@ -411,6 +414,8 @@ def test_lifespan_fails_fast_when_async_engine_cannot_be_built(monkeypatch):
|
||||
with pytest.raises(RuntimeError, match="no async driver"):
|
||||
asyncio.run(run_lifespan())
|
||||
|
||||
configured_loop = lifecycle.global_vars.set_loop.call_args.args[0]
|
||||
lifecycle.global_vars.clear_loop.assert_called_once_with(configured_loop)
|
||||
# 失败要发生在任何东西被初始化之前,否则模块起来了却没人关:关停块在 yield 处才开始
|
||||
lifecycle.init_routers.assert_not_called()
|
||||
lifecycle.init_modules.assert_not_called()
|
||||
|
||||
@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
|
||||
from app.foundation.singleton import Singleton
|
||||
from app.runtime.config import global_vars
|
||||
from app.runtime.extensions.plugin.dependency import (
|
||||
PluginDependencyClassification,
|
||||
PluginDependencyInstallResult,
|
||||
@@ -135,6 +136,11 @@ def _patch_sync_plugins(monkeypatch, manager: MagicMock) -> MagicMock:
|
||||
return task_func()
|
||||
|
||||
register = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
global_vars,
|
||||
"CURRENT_EVENT_LOOP",
|
||||
asyncio.get_running_loop(),
|
||||
)
|
||||
monkeypatch.setattr(plugins_initializer, "configure_plugin_services", lambda: None)
|
||||
monkeypatch.setattr(plugins_initializer, "PluginManager", lambda: manager)
|
||||
monkeypatch.setattr(plugins_initializer, "execute_task", execute)
|
||||
|
||||
@@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.runtime.config import global_vars
|
||||
from app.startup import lifecycle
|
||||
|
||||
|
||||
@@ -23,6 +24,11 @@ async def test_runtime_ready_waits_for_scheduler_and_command_refresh(monkeypatch
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(lifecycle.settings, "MOVIEPILOT_SAFE_MODE", False)
|
||||
monkeypatch.setattr(
|
||||
global_vars,
|
||||
"CURRENT_EVENT_LOOP",
|
||||
asyncio.get_running_loop(),
|
||||
)
|
||||
monkeypatch.setattr(lifecycle, "get_plugin_manager", lambda: manager)
|
||||
monkeypatch.setattr(lifecycle, "sync_plugins", sync_plugins)
|
||||
monkeypatch.setattr(lifecycle, "execute_task", execute_task)
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.chain.message import MessageChain
|
||||
from app.command import Command, _finish_command_processing_status
|
||||
from app.modules.telegram import TelegramModule
|
||||
from app.modules.telegram.telegram import Telegram
|
||||
from app.runtime.config import global_vars
|
||||
from app.schemas.types import NotificationChannel
|
||||
|
||||
|
||||
@@ -263,7 +264,10 @@ class TestTelegramTypingLifecycle(unittest.TestCase):
|
||||
ai_agent_enable=True,
|
||||
)
|
||||
|
||||
with patch.object(chain, "_record_user_message"), patch.object(
|
||||
loop = Mock(**{"is_running.return_value": True, "is_closed.return_value": False})
|
||||
with patch.object(global_vars, "CURRENT_EVENT_LOOP", loop), patch.object(
|
||||
chain, "_record_user_message"
|
||||
), patch.object(
|
||||
chain, "_mark_message_processing_started"
|
||||
) as start_status, patch(
|
||||
"app.chain.message.get_running_agent_manager",
|
||||
|
||||
@@ -4,7 +4,7 @@ import sys
|
||||
from dataclasses import replace
|
||||
from types import ModuleType
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
sys.modules.setdefault("qbittorrentapi", ModuleType("qbittorrentapi"))
|
||||
setattr(sys.modules["qbittorrentapi"], "TorrentFilesList", list)
|
||||
@@ -15,7 +15,7 @@ sys.modules.setdefault("psutil", ModuleType("psutil"))
|
||||
from app.chain.message import MessageChain
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.application.messaging.interaction import InteractionContext
|
||||
from app.runtime.config import settings
|
||||
from app.runtime.config import global_vars, settings
|
||||
from app.schemas.types import NotificationChannel
|
||||
|
||||
|
||||
@@ -132,7 +132,10 @@ class TestTransferFailedRetryButtons(unittest.TestCase):
|
||||
"""关闭被调度的协程:测试中事件循环未运行,不关闭会残留 never-awaited 警告。"""
|
||||
coro.close()
|
||||
|
||||
with patch.object(settings, "AI_AGENT_ENABLE", True):
|
||||
loop = Mock(**{"is_running.return_value": True, "is_closed.return_value": False})
|
||||
with patch.object(global_vars, "CURRENT_EVENT_LOOP", loop), patch.object(
|
||||
settings, "AI_AGENT_ENABLE", True
|
||||
):
|
||||
with patch(
|
||||
"app.chain._transfer.TransferHistoryOper"
|
||||
) as history_oper_cls, patch(
|
||||
@@ -209,7 +212,10 @@ class TestTransferFailedRetryButtons(unittest.TestCase):
|
||||
from app.agent.prompt.transfer_redo import build_manual_redo_prompt
|
||||
|
||||
manager = SimpleNamespace(run_background_prompt=fake_run_background_prompt)
|
||||
with patch.object(settings, "AI_AGENT_ENABLE", True):
|
||||
loop = Mock(**{"is_running.return_value": True, "is_closed.return_value": False})
|
||||
with patch.object(global_vars, "CURRENT_EVENT_LOOP", loop), patch.object(
|
||||
settings, "AI_AGENT_ENABLE", True
|
||||
):
|
||||
with patch(
|
||||
"app.chain._transfer.TransferHistoryOper"
|
||||
) as history_oper_cls, patch(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import Mock, patch
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.chain import transfer as transfer_module
|
||||
@@ -48,6 +48,16 @@ class _Loop:
|
||||
self.timers.append(timer)
|
||||
return timer
|
||||
|
||||
@staticmethod
|
||||
def is_running() -> bool:
|
||||
"""该替身代表由生命周期持有的运行中循环。"""
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def is_closed() -> bool:
|
||||
"""该替身在用例期间保持可用。"""
|
||||
return False
|
||||
|
||||
|
||||
def _task(*, episode: int, download_hash: str = "hash-1") -> TransferTask:
|
||||
"""构造同一媒体不同剧集的整理任务。"""
|
||||
@@ -153,12 +163,13 @@ def test_enabled_queue_uses_shared_group_key():
|
||||
message="整理失败",
|
||||
transfer_type="copy",
|
||||
)
|
||||
loop = transfer_module.global_vars.loop
|
||||
chain.queue_failed_transfer_notification(
|
||||
task=task,
|
||||
transferinfo=transferinfo,
|
||||
history_id=22,
|
||||
)
|
||||
loop = _Loop()
|
||||
with patch.object(transfer_module.global_vars, "CURRENT_EVENT_LOOP", loop):
|
||||
chain.queue_failed_transfer_notification(
|
||||
task=task,
|
||||
transferinfo=transferinfo,
|
||||
history_id=22,
|
||||
)
|
||||
|
||||
chain.failure_notification_aggregator.schedule.assert_called_once()
|
||||
kwargs = chain.failure_notification_aggregator.schedule.call_args.kwargs
|
||||
|
||||
Reference in New Issue
Block a user