From 47d623e4a48a42ee930d5b6895ef4e6cfa9c3c69 Mon Sep 17 00:00:00 2001 From: Liu Date: Fri, 1 May 2026 12:45:29 +0800 Subject: [PATCH] =?UTF-8?q?Reapply=20"=E4=BF=AE=E5=A4=8D=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E8=B6=85=E6=97=B6=E6=8B=96=E6=85=A2=E4=B8=BB?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E9=97=AE=E9=A2=98"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 34adefa931261bf01a39090c1830892f39726138. --- admin/dashboard/blueprints/plugin_routes.py | 1 + plugins/robot_menu/config.toml | 6 +++++- plugins/robot_menu/main.py | 13 ++++++++++++ plugins/robot_menu/menu_render_tool.py | 19 ++++++++++++++--- utils/markdown_to_image.py | 23 ++++++++++++++------- 5 files changed, 51 insertions(+), 11 deletions(-) diff --git a/admin/dashboard/blueprints/plugin_routes.py b/admin/dashboard/blueprints/plugin_routes.py index 40d3dde..a270c16 100644 --- a/admin/dashboard/blueprints/plugin_routes.py +++ b/admin/dashboard/blueprints/plugin_routes.py @@ -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, ) diff --git a/plugins/robot_menu/config.toml b/plugins/robot_menu/config.toml index 0e08ac3..8301164 100644 --- a/plugins/robot_menu/config.toml +++ b/plugins/robot_menu/config.toml @@ -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 diff --git a/plugins/robot_menu/main.py b/plugins/robot_menu/main.py index ee9a880..66b4cfe 100644 --- a/plugins/robot_menu/main.py +++ b/plugins/robot_menu/main.py @@ -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, ) diff --git a/plugins/robot_menu/menu_render_tool.py b/plugins/robot_menu/menu_render_tool.py index b6286c6..2e8bfab 100644 --- a/plugins/robot_menu/menu_render_tool.py +++ b/plugins/robot_menu/menu_render_tool.py @@ -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, ) diff --git a/utils/markdown_to_image.py b/utils/markdown_to_image.py index 9185109..b10c16c 100644 --- a/utils/markdown_to_image.py +++ b/utils/markdown_to_image.py @@ -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: