修复转图浏览器预热跨事件循环失效问题

变更项:\n1. 新增 async_job 启动钩子能力 add_startup_job,在调度器事件循环中执行一次性初始化任务。\n2. 将 main.py 的 Markdown 转图预热从独立线程改为调度器 loop 内执行,确保预热实例可被后续任务复用。\n3. 增强 markdown_to_image 常驻浏览器管理:记录 owner loop、检测跨 loop 复用并自动重建。\n4. 补充预热与常驻浏览器日志,输出 loop 标识和浏览器 PID,便于线上排查进程状态。\n5. 保持现有转图超时与重试逻辑不变,仅修复预热生效链路与可观测性。
This commit is contained in:
liuwei
2026-04-17 09:55:03 +08:00
parent c39b3ba566
commit 3b9bd02b5f
3 changed files with 84 additions and 11 deletions

View File

@@ -517,6 +517,8 @@ class _PersistentBrowser:
self._lock = asyncio.Lock()
self._launch_args = ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu"]
self._last_launch_source = "unknown"
# 记录当前常驻浏览器所属事件循环,避免跨 loop 复用导致的句柄异常。
self._owner_loop_id: Optional[int] = None
async def _launch_browser(self):
if self._playwright is None:
@@ -541,6 +543,18 @@ class _PersistentBrowser:
return browser
async def ensure_browser(self):
current_loop_id = id(asyncio.get_running_loop())
if self._owner_loop_id is not None and self._owner_loop_id != current_loop_id:
# 发生跨事件循环访问时,主动丢弃旧句柄并在新 loop 重建。
# 注意:旧 loop 中的进程资源可能已被 runtime 回收,这里不再尝试跨 loop 强关,避免引入新死锁点。
logger.warning(
f"[md2img] 检测到跨事件循环复用,准备重建常驻浏览器: "
f"owner_loop={self._owner_loop_id}, current_loop={current_loop_id}"
)
self._browser = None
self._playwright = None
self._owner_loop_id = None
if self._browser and self._browser.is_connected():
return self._browser
async with self._lock:
@@ -554,7 +568,12 @@ class _PersistentBrowser:
pass
self._browser = None
self._browser = await self._launch_browser()
logger.info(f"[md2img] 常驻浏览器就绪: source={self._last_launch_source}")
self._owner_loop_id = current_loop_id
browser_pid = getattr(getattr(self._browser, "process", None), "pid", None)
logger.info(
f"[md2img] 常驻浏览器就绪: source={self._last_launch_source}, "
f"loop={self._owner_loop_id}, pid={browser_pid}"
)
return self._browser
async def restart_browser(self):
@@ -566,7 +585,12 @@ class _PersistentBrowser:
pass
self._browser = None
self._browser = await self._launch_browser()
logger.info(f"[md2img] 常驻浏览器已重建: source={self._last_launch_source}")
self._owner_loop_id = id(asyncio.get_running_loop())
browser_pid = getattr(getattr(self._browser, "process", None), "pid", None)
logger.info(
f"[md2img] 常驻浏览器已重建: source={self._last_launch_source}, "
f"loop={self._owner_loop_id}, pid={browser_pid}"
)
return self._browser
async def screenshot(self, html_content: str, output_image: str):
@@ -618,9 +642,13 @@ 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)))
logger.info("[md2img] 浏览器预热完成")
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}")
return True
except Exception as e:
logger.error(f"[md2img] 浏览器预热失败: {e}")