diff --git a/utils/markdown_to_image.py b/utils/markdown_to_image.py index 2cf9ec8..eed4468 100644 --- a/utils/markdown_to_image.py +++ b/utils/markdown_to_image.py @@ -3,6 +3,8 @@ import time from pathlib import Path import shutil from typing import Optional, Tuple +import threading +from concurrent.futures import Future as ConcurrentFuture import psutil from playwright.async_api import async_playwright @@ -625,6 +627,83 @@ class _PersistentBrowser: _BROWSER_MANAGER: Optional[_PersistentBrowser] = None +_MD2IMG_RUNTIME = None + + +class _Md2ImgRuntime: + """Markdown 转图专用运行时。 + + 设计目的: + 1. 在独立线程中维护唯一事件循环,所有浏览器操作都在这个 loop 执行; + 2. 彻底避免“预热在 A loop、截图在 B loop”的跨 loop 复用问题; + 3. 为消息处理与定时任务提供统一稳定的浏览器执行上下文。 + """ + + def __init__(self): + self._thread: Optional[threading.Thread] = None + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._lock = threading.Lock() + self._ready = threading.Event() + + @property + def loop(self) -> Optional[asyncio.AbstractEventLoop]: + return self._loop + + def _thread_main(self): + """运行时线程入口:创建并常驻事件循环。""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + self._loop = loop + self._ready.set() + logger.info(f"[md2img] 专用运行时已启动: thread={threading.current_thread().name}, loop={id(loop)}") + loop.run_forever() + + def ensure_started(self): + """确保运行时已启动(幂等)。""" + if self._thread and self._thread.is_alive() and self._loop and self._loop.is_running(): + return + with self._lock: + if self._thread and self._thread.is_alive() and self._loop and self._loop.is_running(): + return + self._ready.clear() + self._thread = threading.Thread(target=self._thread_main, name="md2img-runtime", daemon=True) + self._thread.start() + if not self._ready.wait(timeout=10): + raise RuntimeError("md2img 专用运行时启动超时") + + def submit(self, coro) -> ConcurrentFuture: + """向专用运行时提交协程任务。""" + self.ensure_started() + if not self._loop: + raise RuntimeError("md2img 运行时事件循环未就绪") + return asyncio.run_coroutine_threadsafe(coro, self._loop) + + +def _get_md2img_runtime() -> _Md2ImgRuntime: + global _MD2IMG_RUNTIME + if _MD2IMG_RUNTIME is None: + _MD2IMG_RUNTIME = _Md2ImgRuntime() + return _MD2IMG_RUNTIME + + +async def _run_in_md2img_runtime(coro, timeout_seconds: Optional[int] = None): + """在 md2img 专用事件循环中执行协程,并在当前调用方 loop 中异步等待结果。""" + runtime = _get_md2img_runtime() + runtime.ensure_started() + target_loop = runtime.loop + current_loop = asyncio.get_running_loop() + + # 若当前已在专用 loop 内,直接执行,避免不必要的线程跳转。 + if target_loop is current_loop: + if timeout_seconds is not None: + return await asyncio.wait_for(coro, timeout=max(1, int(timeout_seconds))) + return await coro + + 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 awaitable_future def _get_browser_manager() -> _PersistentBrowser: @@ -642,13 +721,17 @@ async def warmup_md2img_browser(timeout_seconds: int = 45) -> bool: 2. 不执行实际业务截图,仅确保常驻浏览器已可用。 """ try: - current_loop_id = id(asyncio.get_running_loop()) - logger.info(f"[md2img] 开始浏览器预热: loop={current_loop_id}, timeout={int(timeout_seconds)}s") - manager = _get_browser_manager() - await asyncio.wait_for(manager.ensure_browser(), timeout=max(10, int(timeout_seconds))) - browser = manager._browser - browser_pid = getattr(getattr(browser, "process", None), "pid", None) if browser else None - logger.info(f"[md2img] 浏览器预热完成: loop={current_loop_id}, pid={browser_pid}") + logger.info(f"[md2img] 开始浏览器预热: caller_loop={id(asyncio.get_running_loop())}, timeout={int(timeout_seconds)}s") + + async def _warmup_impl(): + manager = _get_browser_manager() + await asyncio.wait_for(manager.ensure_browser(), timeout=max(10, int(timeout_seconds))) + browser = manager._browser + browser_pid = getattr(getattr(browser, "process", None), "pid", None) if browser else None + logger.info(f"[md2img] 浏览器预热完成: runtime_loop={id(asyncio.get_running_loop())}, pid={browser_pid}") + return True + + await _run_in_md2img_runtime(_warmup_impl(), timeout_seconds=max(10, int(timeout_seconds) + 5)) return True except Exception as e: logger.error(f"[md2img] 浏览器预热失败: {e}") @@ -665,8 +748,18 @@ def warmup_md2img_browser_sync(timeout_seconds: int = 45) -> bool: async def html_to_image(html_content, output_image): - manager = _get_browser_manager() - await manager.screenshot(html_content, output_image) + """将 HTML 渲染为图片。 + + 说明: + 1. 实际截图逻辑固定在 md2img 专用事件循环执行; + 2. 调用方无论来自哪个线程/loop,都只会复用同一套常驻浏览器。 + """ + + async def _html_to_image_impl(): + manager = _get_browser_manager() + await manager.screenshot(html_content, output_image) + + await _run_in_md2img_runtime(_html_to_image_impl()) async def _await_with_progress(coro, timeout_seconds: int, stage_name: str, progress_interval_seconds: int = 10):