Reapply "修复菜单插件超时拖慢主链路问题"

This reverts commit 34adefa931.
This commit is contained in:
Liu
2026-05-01 12:45:29 +08:00
parent 994f452b99
commit 47d623e4a4
5 changed files with 51 additions and 11 deletions

View File

@@ -20,6 +20,7 @@ _command_catalog_tool = RobotMenuRenderTool(
image_fallback_to_text=True,
image_render_timeout_seconds=30,
image_render_retries=1,
sync_send_timeout_seconds=10,
image_template_path="plugins/robot_menu/templates/menu_cards.html",
log=LOG,
)

View File

@@ -8,7 +8,11 @@ output_mode = "image"
# 图片生成失败时是否回退文本菜单:
# - false严格按图片模式不发送完整菜单文本
# - true优先保证可达失败后改发文本
image_fallback_to_text = false
image_fallback_to_text = true
# 菜单命令是即时交互,不允许长时间占住主消息链路:
# - 这里控制“同步等待图片发送完成”的最长时长;
# - 超过后会尽快回退文本或失败提示,避免把整个插件处理流程拖慢。
sync_send_timeout_seconds = 18
# md2image 渲染参数:可按服务器性能调整
image_render_timeout_seconds = 45
image_render_retries = 1

View File

@@ -87,6 +87,18 @@ class RobotMenuPlugin(MessagePluginInterface):
self.image_render_retries = int(
self._config.get("RobotMenu", {}).get("image_render_retries", 1)
)
# 菜单命令属于强交互型消息:
# 1. 用户输入“菜单”后,不能允许单次渲染长期霸占消息处理协程;
# 2. 因此这里单独定义“主链路同步等待预算”,超出后立即由渲染工具降级;
# 3. 该预算默认比底层图片渲染超时短很多,优先保障机器人整体吞吐稳定。
self.sync_send_timeout_seconds = int(
self._config.get("RobotMenu", {}).get("sync_send_timeout_seconds", 18)
)
# 对外层插件保护显式声明一个更合适的总超时:
# 1. 内层菜单发送会在 sync_send_timeout_seconds 内决定“成功发图 / 回退文本 / 返回失败提示”;
# 2. 外层 wait_for 必须比内层稍长,给降级发送文本留出缓冲;
# 3. 这样可以避免过去“内外层都卡在 55 秒”时,外层先打断,导致降级逻辑来不及执行。
self.plugin_process_timeout_seconds = max(12, self.sync_send_timeout_seconds + 8)
# 菜单图片模板文件路径(相对仓库根目录):
# 调整样式和布局时只改模板,不改 Python 逻辑。
self.image_template_path = str(
@@ -101,6 +113,7 @@ class RobotMenuPlugin(MessagePluginInterface):
image_fallback_to_text=self.image_fallback_to_text,
image_render_timeout_seconds=self.image_render_timeout_seconds,
image_render_retries=self.image_render_retries,
sync_send_timeout_seconds=self.sync_send_timeout_seconds,
image_template_path=self.image_template_path,
log=self.LOG,
)

View File

@@ -30,6 +30,7 @@ class RobotMenuRenderTool:
image_fallback_to_text: bool,
image_render_timeout_seconds: int,
image_render_retries: int,
sync_send_timeout_seconds: int,
image_template_path: str,
log=default_logger,
):
@@ -40,6 +41,11 @@ class RobotMenuRenderTool:
# 渲染超时与重试参数,统一集中在工具层处理。
self.image_render_timeout_seconds = int(image_render_timeout_seconds)
self.image_render_retries = int(image_render_retries)
# 同步发送超时预算:
# 1. “菜单”属于即时交互命令,不能像离线任务一样长时间占住消息处理协程;
# 2. 因此这里单独维护一个“主链路最多等多久”的预算,超时后立即进入降级逻辑;
# 3. 图片渲染器即便本身还能继续尝试,也不允许把主消息链路拖成几十秒假死。
self.sync_send_timeout_seconds = max(8, int(sync_send_timeout_seconds or 18))
# 注入日志对象,便于主插件统一控制日志风格与输出目标。
self.log = log or default_logger
# 菜单图片模板路径(相对仓库根目录),支持仅改模板文件完成 UI 更新。
@@ -487,7 +493,14 @@ class RobotMenuRenderTool:
md_content = (markdown_content or "").strip() or f"```text\n{text_content}\n```"
output_image = f"robot_menu_{int(time.time() * 1000)}.png"
try:
total_timeout = max(30, self.image_render_timeout_seconds * max(1, self.image_render_retries) + 10)
# 这里故意不再使用“渲染超时 * 重试次数 + 缓冲”的长预算:
# 1. 菜单是即时命令,用户更在意“尽快拿到结果或降级结果”,而不是死等图片;
# 2. 若沿用 45~55 秒预算,多个菜单命令会持续占住机器人并发槽位,放大成“整条插件链路卡住”;
# 3. 因此统一按 sync_send_timeout_seconds 控制主链路等待时间,超时后快速回退。
total_timeout = max(8, int(self.sync_send_timeout_seconds or 18))
# Markdown 转图内部也要用更短预算,避免内外层超时完全重合,导致降级逻辑来不及执行。
html_budget_seconds = max(5, min(10, total_timeout - 4))
render_budget_seconds = max(6, total_timeout - 2)
output_dir = Path(os.getcwd()) / "temp" / "md2image"
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / output_image
@@ -503,8 +516,8 @@ class RobotMenuRenderTool:
md_content,
output_image,
max_retries=max(1, self.image_render_retries),
render_timeout_seconds=max(10, self.image_render_timeout_seconds),
html_timeout_seconds=min(30, max(10, self.image_render_timeout_seconds)),
render_timeout_seconds=render_budget_seconds,
html_timeout_seconds=html_budget_seconds,
),
timeout=total_timeout,
)

View File

@@ -864,13 +864,22 @@ async def _run_in_md2img_runtime(coro, timeout_seconds: Optional[int] = None):
future = runtime.submit(coro)
awaitable_future = asyncio.wrap_future(future)
if timeout_seconds is not None:
return await asyncio.wait_for(awaitable_future, timeout=max(1, int(timeout_seconds)))
# 关键修复:
# 之前这里直接 return Future 对象,调用方 await 后只拿到 Future 本身,
# 导致业务层误以为截图已完成,实际截图仍在后台执行,出现“先判失败后截图”的时序错乱。
# 这里必须等待 Future 完成并返回真实结果,保证调用链严格串行
return await awaitable_future
try:
if timeout_seconds is not None:
return await asyncio.wait_for(awaitable_future, timeout=max(1, int(timeout_seconds)))
# 关键修复:
# 之前这里直接 return Future 对象,调用方 await 后只拿到 Future 本身,
# 导致业务层误以为截图已完成,实际截图仍在后台执行,出现“先判失败后截图”的时序错乱
# 这里必须等待 Future 完成并返回真实结果,保证调用链严格串行。
return await awaitable_future
except (asyncio.TimeoutError, asyncio.CancelledError):
# 这里要把“当前调用方已放弃等待”的状态显式同步给 md2img 专用事件循环:
# 1. 菜单、总结这类交互命令一旦在主链路超时,若不取消专用 loop 里的任务,
# 浏览器截图仍可能继续跑完,白白占着浏览器与事件循环资源;
# 2. 多次超时后,残留任务会在后台堆积,看起来像“插件流程整体卡住”;
# 3. 因此这里在超时/取消时主动 future.cancel(),让下游尽快停止当前截图任务。
future.cancel()
raise
def _get_browser_manager() -> _PersistentBrowser: