修复转图浏览器预热跨事件循环失效问题
变更项:\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:
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user