统一转图为单运行时常驻浏览器
变更项:\n1. 新增 md2img 专用运行时(独立线程 + 单事件循环),确保浏览器生命周期只在一个 loop 内维护。\n2. 新增运行时任务投递与异步等待封装,支持任意调用方线程/loop 统一提交截图任务。\n3. 调整浏览器预热逻辑:预热改为在 md2img 专用运行时执行,避免预热与业务截图分属不同 loop。\n4. 调整 html_to_image:统一在专用运行时内完成截图,彻底规避跨事件循环复用导致的重建。\n5. 增强中文注释与运行日志,便于定位 runtime loop 与预热状态。
This commit is contained in:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user