mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-03 22:51:47 +08:00
fix(runtime): 收敛模块关闭线程所有权 (#6443)
* fix(runtime): bound module shutdown owners * fix(runtime): declare blocking shutdown owners --------- Co-authored-by: jxxghp <jxxghp@gmail.com>
This commit is contained in:
+4
-2
@@ -13,8 +13,8 @@
|
||||
"runtime_to_db": [],
|
||||
"workflow_to_db": []
|
||||
},
|
||||
"edge_count": 6601,
|
||||
"edge_sha256": "4ebc1db325d75ac04418e89c199dc2b09eb95905e6e03a4101b7780d6b184c7c",
|
||||
"edge_count": 6603,
|
||||
"edge_sha256": "70ed941e5ae3893b29167aa191357df42847de4547ce4a0b8d29acf38bbf924d",
|
||||
"edges": [
|
||||
"app -> app.runtime",
|
||||
"app -> app.runtime.compat",
|
||||
@@ -6302,6 +6302,7 @@
|
||||
"app.startup.initializers.modules -> app.runtime.cache",
|
||||
"app.startup.initializers.modules -> app.runtime.config",
|
||||
"app.startup.initializers.modules -> app.runtime.events",
|
||||
"app.startup.initializers.modules -> app.runtime.execution",
|
||||
"app.startup.initializers.modules -> app.runtime.extensions",
|
||||
"app.startup.initializers.modules -> app.runtime.extensions.module",
|
||||
"app.startup.initializers.modules -> app.runtime.extensions.module.dispatcher",
|
||||
@@ -6404,6 +6405,7 @@
|
||||
"app.startup.lifecycle -> app.foundation.environment",
|
||||
"app.startup.lifecycle -> app.runtime",
|
||||
"app.startup.lifecycle -> app.runtime.config",
|
||||
"app.startup.lifecycle -> app.runtime.execution",
|
||||
"app.startup.lifecycle -> app.runtime.health",
|
||||
"app.startup.lifecycle -> app.runtime.log",
|
||||
"app.startup.lifecycle -> app.runtime.settings",
|
||||
|
||||
@@ -1054,6 +1054,26 @@ def test_stop_modules_propagates_doh_nonconvergence(monkeypatch):
|
||||
_assert_completed_once(dependency)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_modules_keeps_event_loop_responsive_during_sync_owner_wait(
|
||||
monkeypatch,
|
||||
):
|
||||
"""同步 owner 的有界等待不得占用主事件循环。"""
|
||||
dependencies = _patch_module_shutdown_dependencies(monkeypatch)
|
||||
release = threading.Event()
|
||||
timer = threading.Timer(0.05, release.set)
|
||||
dependencies["module"].side_effect = lambda: release.wait(timeout=1.0)
|
||||
|
||||
heartbeat = asyncio.create_task(asyncio.sleep(0.01))
|
||||
timer.start()
|
||||
try:
|
||||
await modules_initializer.stop_modules()
|
||||
finally:
|
||||
timer.join(timeout=1.0)
|
||||
|
||||
assert heartbeat.done()
|
||||
|
||||
|
||||
def test_stop_modules_drains_web_agent_tasks_before_persistence(monkeypatch):
|
||||
"""关闭时先收口 Web Agent,再关闭持久化准入和数据库任务。"""
|
||||
order = []
|
||||
@@ -1120,7 +1140,8 @@ async def test_shutdown_timeout_does_not_skip_database_worker_cleanup(monkeypatc
|
||||
"get_configured_agent_chat_persistence",
|
||||
MagicMock(return_value=persistence),
|
||||
)
|
||||
stop_database_worker = AsyncMock()
|
||||
database_worker_stopped = asyncio.Event()
|
||||
stop_database_worker = AsyncMock(side_effect=database_worker_stopped.set)
|
||||
monkeypatch.setattr(modules_initializer, "stop_database_worker", stop_database_worker)
|
||||
monkeypatch.setattr(modules_initializer, "_database_worker", object())
|
||||
|
||||
@@ -1135,6 +1156,7 @@ async def test_shutdown_timeout_does_not_skip_database_worker_cleanup(monkeypatc
|
||||
completed = await shutdown
|
||||
|
||||
assert completed is False
|
||||
await asyncio.wait_for(database_worker_stopped.wait(), timeout=1.0)
|
||||
stop_database_worker.assert_awaited_once_with()
|
||||
|
||||
|
||||
@@ -1177,6 +1199,54 @@ async def test_shutdown_timeout_has_hard_bound_for_nonconverging_cleanup() -> No
|
||||
await asyncio.wait_for(settled.wait(), timeout=0.2)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_step_bounds_sync_owner_without_blocking_event_loop() -> None:
|
||||
"""同步 owner 超时后应及时返回,并继续持有 worker 直至真实终态。"""
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
settled = threading.Event()
|
||||
|
||||
def blocking_shutdown() -> None:
|
||||
started.set()
|
||||
release.wait(timeout=1.0)
|
||||
settled.set()
|
||||
|
||||
heartbeat = asyncio.create_task(asyncio.sleep(0.01))
|
||||
shutdown = asyncio.create_task(
|
||||
lifecycle.run_shutdown_step(
|
||||
"同步阻塞 owner",
|
||||
lifecycle.offload_shutdown_callback(blocking_shutdown),
|
||||
timeout_seconds=0.02,
|
||||
)
|
||||
)
|
||||
assert await asyncio.to_thread(started.wait, 0.2)
|
||||
started_at = asyncio.get_running_loop().time()
|
||||
completed = await shutdown
|
||||
|
||||
assert completed is False
|
||||
assert asyncio.get_running_loop().time() - started_at < 0.2
|
||||
assert heartbeat.done()
|
||||
assert not settled.is_set()
|
||||
|
||||
release.set()
|
||||
assert await asyncio.to_thread(settled.wait, 0.2)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_step_calls_awaitable_wrapper_on_event_loop() -> None:
|
||||
"""普通 callable 可在主循环构造并返回需要等待的异步结果。"""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def shutdown_wrapper() -> asyncio.Task[None]:
|
||||
assert asyncio.get_running_loop() is loop
|
||||
return loop.create_task(asyncio.sleep(0))
|
||||
|
||||
assert await lifecycle.run_shutdown_step(
|
||||
"异步包装 owner",
|
||||
shutdown_wrapper,
|
||||
) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_step_reports_explicit_nonconvergence() -> None:
|
||||
"""同步和异步 owner 显式返回 False 时都必须向生命周期传播失败。"""
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
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
|
||||
@@ -23,6 +26,7 @@ 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():
|
||||
@@ -180,6 +184,8 @@ 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]
|
||||
@@ -193,9 +199,9 @@ def test_telegram_stop_closes_sdk_and_waits_for_polling_thread():
|
||||
assert client.stop() is True
|
||||
assert client.stop() is True
|
||||
|
||||
bot.stop_bot.assert_called_once_with()
|
||||
bot.stop_polling.assert_called_once_with()
|
||||
polling_thread.join.assert_called_once_with(
|
||||
timeout=client._polling_join_timeout_seconds
|
||||
timeout=pytest.approx(client._shutdown_timeout_seconds, abs=0.1)
|
||||
)
|
||||
assert client._bot is None
|
||||
assert client._polling_thread is None
|
||||
@@ -205,11 +211,13 @@ 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._polling_join_timeout_seconds = 0.01
|
||||
client._shutdown_timeout_seconds = 0.01
|
||||
client._typing_tasks = {}
|
||||
client._typing_stop_flags = {}
|
||||
client._typing_lock = threading.RLock()
|
||||
@@ -218,11 +226,60 @@ def test_telegram_stop_keeps_polling_owner_when_thread_misses_deadline():
|
||||
|
||||
assert client.stop() is False
|
||||
|
||||
polling_thread.join.assert_called_once_with(timeout=0.01)
|
||||
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",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user