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:
InfinityPacer
2026-08-25 06:58:31 +08:00
committed by GitHub
co-authored by jxxghp
parent 6c334f7b9b
commit be7dfd77a3
8 changed files with 421 additions and 48 deletions
+28 -4
View File
@@ -84,7 +84,7 @@ class Telegram:
_typing_command_max_duration_seconds = 30
_typing_callback_max_duration_seconds = 60
_typing_join_timeout_seconds = 1
_polling_join_timeout_seconds = 10
_shutdown_timeout_seconds = 10
def __init__(
self,
@@ -1743,9 +1743,25 @@ class Telegram:
# 清理菜单命令
self._bot.delete_my_commands()
@staticmethod
def _stop_bot_with_deadline(bot: TeleBot, deadline: float) -> bool:
"""停止 SDK polling,并在共享 deadline 内等待 worker 收敛。"""
bot.stop_polling()
if not bot.threaded or not bot.worker_pool:
return True
workers = tuple(bot.worker_pool.workers)
for worker in workers:
worker.stop()
for worker in workers:
if worker is threading.current_thread():
continue
worker.join(timeout=max(0.0, deadline - time.monotonic()))
return all(not worker.is_alive() for worker in workers)
def stop(self) -> bool:
"""
停止 Telegram 消息接收服务,并返回 polling/typing owner 是否收敛。
停止 Telegram 消息接收服务,并返回 SDK/polling/typing owner 是否收敛。
"""
converged = True
with self._typing_lifecycle_lock:
@@ -1757,16 +1773,24 @@ class Telegram:
bot = self._bot
polling_thread = self._polling_thread
deadline = time.monotonic() + self._shutdown_timeout_seconds
transport_converged = True
if bot:
bot.stop_bot()
if not self._stop_bot_with_deadline(bot, deadline):
converged = False
transport_converged = False
logger.error("Telegram SDK worker 未在关闭预算内退出")
if (
polling_thread
and polling_thread.is_alive()
and polling_thread is not threading.current_thread()
):
polling_thread.join(timeout=self._polling_join_timeout_seconds)
polling_thread.join(timeout=max(0.0, deadline - time.monotonic()))
if polling_thread and polling_thread.is_alive():
logger.error("Telegram polling 线程未在关闭预算内退出")
converged = False
transport_converged = False
if not transport_converged:
return False
self._polling_thread = None
self._bot = None
+14 -9
View File
@@ -27,6 +27,7 @@ from app.runtime.extensions.module_manager import ModuleManager
from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher
from app.runtime.extensions.plugin_manager import PluginManager
from app.runtime.events import EventHandlerBinding, EventManager
from app.runtime.execution import run_in_threadpool_to_completion
from app.runtime.observability import record_metric
from app.runtime.state import SystemHelper
from app.runtime.settings import configure_runtime_setting_provider
@@ -611,12 +612,16 @@ async def stop_modules() -> bool:
name: str,
callback: Callable[[], object],
*,
offload: bool = False,
record_failure: bool = True,
) -> bool:
"""执行单个关闭步骤,失败时继续收口并保留诚实结果。"""
nonlocal all_converged
try:
result = callback()
if offload:
result = await run_in_threadpool_to_completion(callback)
else:
result = callback()
if inspect.isawaitable(result):
result = await result
converged = result is not False
@@ -633,14 +638,14 @@ async def stop_modules() -> bool:
return converged
await run_step("图片代理安全日志合并器", close_image_proxy_block_log_coalescer)
await run_step("模块", lambda: ModuleManager().shutdown())
await run_step("模块", lambda: ModuleManager().shutdown(), offload=True)
await run_step("事件消费", lambda: EventManager().stop_async())
await run_step("浏览器会话", close_browser_sessions)
await run_step("浏览器会话", close_browser_sessions, offload=True)
await run_step("托管资源", stop_managed_resources)
await run_step("DoH服务", lambda: DohHelper().shutdown())
await run_step("线程池", lambda: ThreadHelper().shutdown())
await run_step("消息服务", stop_message)
await run_step("Redis缓存连接", lambda: RedisHelper().close())
await run_step("DoH服务", lambda: DohHelper().shutdown(), offload=True)
await run_step("线程池", lambda: ThreadHelper().shutdown(), offload=True)
await run_step("消息服务", stop_message, offload=True)
await run_step("Redis缓存连接", lambda: RedisHelper().close(), offload=True)
await run_step("异步Redis缓存连接", lambda: AsyncRedisHelper().close())
# Web Agent 的取消 finally 可能还要写入最终展示快照,必须先完成任务收尾,再关闭写入准入。
web_agent_drained = await run_step(
@@ -672,8 +677,8 @@ async def stop_modules() -> bool:
else:
all_converged = False
logger.error("数据库任务未收敛,跳过数据库连接关闭以避免运行中事务使用已释放连接")
await run_step("前端服务", stop_frontend)
await run_step("临时文件", clear_temp)
await run_step("前端服务", stop_frontend, offload=True)
await run_step("临时文件", clear_temp, offload=True)
return all_converged
+48 -28
View File
@@ -4,7 +4,7 @@ import asyncio
import inspect
import time
from contextlib import asynccontextmanager
from typing import Callable
from typing import Awaitable, Callable
from fastapi import FastAPI
@@ -33,6 +33,7 @@ from app.foundation.environment import is_free_threaded_runtime, is_gil_enabled
settings = RuntimeSettingsCompat()
from app.runtime.health import get_application_health
from app.runtime.execution import run_in_threadpool_to_completion
from app.runtime.topology import validate_process_topology
from app.runtime.tasks import TaskRegistry, configure_task_registry
from app.adapters.external.server import MoviePilotServerHelper
@@ -135,32 +136,38 @@ async def run_shutdown_step(
timeout_seconds: float | None = None,
) -> bool:
"""在有限预算内执行关闭阶段,并返回资源 owner 是否已经收敛。"""
try:
async def invoke() -> object:
"""在主循环调用 owner,并等待其可能返回的异步结果。"""
result = callback()
if inspect.isawaitable(result):
task = asyncio.ensure_future(result)
return await result
return result
def _consume_shutdown_result(done: asyncio.Future) -> None:
"""消费延迟收敛任务的最终异常,避免事件循环产生未取回异常。"""
try:
done.result()
except asyncio.CancelledError:
pass
except Exception as err:
logger.error(f"关闭{name}最终收尾失败:{err}")
try:
task = asyncio.create_task(invoke(), name=f"shutdown.{name}")
task.add_done_callback(_consume_shutdown_result)
if timeout_seconds:
try:
result = await asyncio.wait_for(
asyncio.shield(task), timeout=timeout_seconds
)
except asyncio.TimeoutError:
logger.error("关闭%s超时,已请求取消并保留未收敛任务", name)
task.cancel()
return False
else:
result = await task
def _consume_shutdown_result(done: asyncio.Future) -> None:
"""消费延迟收敛任务的最终异常,避免事件循环产生未取回异常。"""
try:
done.result()
except asyncio.CancelledError:
pass
except Exception as err:
logger.error(f"关闭{name}最终收尾失败:{err}")
task.add_done_callback(_consume_shutdown_result)
if timeout_seconds:
try:
result = await asyncio.wait_for(
asyncio.shield(task), timeout=timeout_seconds
)
except asyncio.TimeoutError:
logger.error("关闭%s超时,已请求取消并保留未收敛任务", name)
task.cancel()
return False
else:
result = await task
if result is False:
logger.error("关闭%s未收敛,资源所有权保持不变", name)
return False
@@ -170,6 +177,17 @@ async def run_shutdown_step(
return False
def offload_shutdown_callback(
callback: Callable[[], object],
) -> Callable[[], Awaitable[object]]:
"""把明确会阻塞的同步关闭 owner 包装为异步生命周期回调。"""
async def invoke() -> object:
return await run_in_threadpool_to_completion(callback)
return invoke
async def run_startup_step(
name: str,
callback: Callable[[], object],
@@ -384,7 +402,7 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]:
dependencies=("插件备份恢复",),
mode=LifecycleMode.NORMAL_ONLY,
start=init_plugins,
stop=finalize_plugins,
stop=offload_shutdown_callback(finalize_plugins),
start_order=90,
stop_order=60,
start_timeout_seconds=300,
@@ -395,7 +413,7 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]:
name="插件变更监控",
dependencies=("插件",),
mode=LifecycleMode.NORMAL_ONLY,
stop=stop_plugin_monitor,
stop=offload_shutdown_callback(stop_plugin_monitor),
stop_order=8,
stop_timeout_seconds=10,
stop_failure=LifecycleFailurePolicy.FAIL_FAST,
@@ -405,7 +423,7 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]:
dependencies=("插件",),
mode=LifecycleMode.NORMAL_ONLY,
start=init_scheduler,
stop=stop_scheduler,
stop=offload_shutdown_callback(stop_scheduler),
start_order=100,
stop_order=50,
start_timeout_seconds=120,
@@ -496,7 +514,7 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]:
dependencies=("命令服务",),
mode=LifecycleMode.NORMAL_ONLY,
start=init_workflow,
stop=stop_workflow,
stop=offload_shutdown_callback(stop_workflow),
start_order=140,
stop_order=20,
start_timeout_seconds=120,
@@ -507,7 +525,9 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]:
name="插件备份",
dependencies=("插件",),
mode=LifecycleMode.NORMAL_ONLY,
stop=lambda: SystemChain().backup_plugins(),
stop=offload_shutdown_callback(
lambda: SystemChain().backup_plugins()
),
stop_order=10,
stop_timeout_seconds=300,
),