fix(runtime): enforce main event loop ownership (#6424)

This commit is contained in:
InfinityPacer
2026-08-23 20:14:31 +08:00
committed by GitHub
parent c72b4fe88e
commit 1d2984b95f
14 changed files with 181 additions and 55 deletions
+2 -2
View File
@@ -143,8 +143,8 @@ def _pooled_loop() -> Optional[Any]:
只认常驻主循环:它承载了绝大多数异步 DB 流量,且生命周期与进程一致,
池中连接不会因循环销毁而失效。
直接读 CURRENT_EVENT_LOOP 而不用 global_vars.loop——后者在未设置时会
新建一个事件循环,仅为判断就产生副作用是不可接受的
直接读 CURRENT_EVENT_LOOP 而不用 global_vars.loop,避免主循环尚未就绪时
把正常的回退引擎选择转换成生命周期异常
"""
if not _async_pool_enabled():
return None
+14 -23
View File
@@ -1,4 +1,3 @@
import asyncio
import copy
import json
import os
@@ -1359,18 +1358,8 @@ class GlobalVar(object):
EMERGENCY_STOP_WORKFLOWS: List[int] = []
# 需应急停止文件整理
EMERGENCY_STOP_TRANSFER: List[str] = []
# 当前事件循环
CURRENT_EVENT_LOOP: AbstractEventLoop = None
@classmethod
def _get_event_loop(cls) -> AbstractEventLoop:
"""返回当前线程事件循环,缺失时创建并绑定新循环。"""
try:
return asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop
# 生命周期登记的主事件循环
CURRENT_EVENT_LOOP: Optional[AbstractEventLoop] = None
def stop_system(self):
"""
@@ -1461,19 +1450,21 @@ class GlobalVar(object):
@property
def loop(self) -> AbstractEventLoop:
"""
当前循环
"""
if self.CURRENT_EVENT_LOOP is None:
self.CURRENT_EVENT_LOOP = self._get_event_loop()
return self.CURRENT_EVENT_LOOP
"""返回由应用生命周期登记的主事件循环。"""
loop = self.CURRENT_EVENT_LOOP
if loop is None or not loop.is_running() or loop.is_closed():
raise RuntimeError("主事件循环尚未启动或已经停止")
return loop
def set_loop(self, loop: AbstractEventLoop):
"""
设置循环
"""
def set_loop(self, loop: AbstractEventLoop) -> None:
"""登记承载主程序异步任务的事件循环。"""
self.CURRENT_EVENT_LOOP = loop
def clear_loop(self, loop: AbstractEventLoop) -> None:
"""仅在登记值仍为目标循环时清除主事件循环。"""
if self.CURRENT_EVENT_LOOP is loop:
self.CURRENT_EVENT_LOOP = None
# 全局标识
global_vars = GlobalVar()
+9 -4
View File
@@ -368,14 +368,14 @@ async def lifespan(app: FastAPI):
"""
health = get_application_health(app)
health.begin_startup()
main_loop = asyncio.get_running_loop()
try:
validate_process_topology(
workers=settings.API_WORKERS,
safe_mode=settings.MOVIEPILOT_SAFE_MODE,
)
print("Starting up...")
# 存储当前循环
global_vars.set_loop(asyncio.get_event_loop())
global_vars.set_loop(main_loop)
components = build_lifecycle_components(app)
enabled_components = tuple(
component
@@ -411,6 +411,8 @@ async def lifespan(app: FastAPI):
await stop_task_registry(app)
except Exception as cleanup_error:
logger.error(f"启动失败后的后台任务清理失败:{cleanup_error}")
finally:
global_vars.clear_loop(main_loop)
raise
try:
# 在此处 yield,表示应用已经启动,控制权交回 FastAPI 主事件循环
@@ -435,5 +437,8 @@ async def lifespan(app: FastAPI):
component.stop_timeout_seconds,
)
finally:
# 日志最后关闭,确保其他组件的收尾信息已写入文件
LoggerManager.shutdown()
try:
# 日志最后关闭,确保其他组件的收尾信息已写入文件
LoggerManager.shutdown()
finally:
global_vars.clear_loop(main_loop)
+4 -2
View File
@@ -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
+9 -3
View File
@@ -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"
+5 -2
View File
@@ -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"
+28 -7
View File
@@ -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
+60
View File
@@ -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()
+5
View File
@@ -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()
+6
View File
@@ -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)
+5 -1
View File
@@ -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",
+10 -4
View File
@@ -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