diff --git a/utils/markdown_to_image.py b/utils/markdown_to_image.py index 39c51be..8dadfcb 100644 --- a/utils/markdown_to_image.py +++ b/utils/markdown_to_image.py @@ -521,6 +521,8 @@ class _PersistentBrowser: self._last_launch_source = "unknown" # 记录当前常驻浏览器所属事件循环,避免跨 loop 复用导致的句柄异常。 self._owner_loop_id: Optional[int] = None + # 保活心跳任务:定期探测浏览器连通性,异常时自动重建。 + self._heartbeat_task: Optional[asyncio.Task] = None async def _launch_browser(self): if self._playwright is None: @@ -576,6 +578,7 @@ class _PersistentBrowser: f"[md2img] 常驻浏览器就绪: source={self._last_launch_source}, " f"loop={self._owner_loop_id}, pid={browser_pid}" ) + self._ensure_heartbeat_task() return self._browser async def restart_browser(self): @@ -593,8 +596,41 @@ class _PersistentBrowser: f"[md2img] 常驻浏览器已重建: source={self._last_launch_source}, " f"loop={self._owner_loop_id}, pid={browser_pid}" ) + self._ensure_heartbeat_task() return self._browser + async def _is_browser_alive(self, browser, timeout_seconds: float = 3.0) -> bool: + """探测浏览器是否仍可用。""" + if not browser or not browser.is_connected(): + return False + try: + await asyncio.wait_for(browser.version(), timeout=timeout_seconds) + return True + except Exception: + return False + + async def _heartbeat_loop(self): + """周期性探测浏览器可用性,断连后自动重建。""" + while True: + try: + await asyncio.sleep(10) + # 没有浏览器实例时只保持心跳存活,不主动创建,避免空闲时不必要消耗。 + if not self._browser: + continue + if not await self._is_browser_alive(self._browser, timeout_seconds=2.0): + logger.warning("[md2img] 心跳探测发现浏览器已断连,准备自动重建") + await self.restart_browser() + except asyncio.CancelledError: + raise + except Exception as e: + logger.warning(f"[md2img] 心跳探测异常: {e}") + + def _ensure_heartbeat_task(self): + """确保保活任务已启动(幂等)。""" + if self._heartbeat_task and not self._heartbeat_task.done(): + return + self._heartbeat_task = asyncio.create_task(self._heartbeat_loop(), name="md2img:heartbeat") + async def screenshot(self, html_content: str, output_image: str): browser = await self.ensure_browser() @@ -619,6 +655,11 @@ class _PersistentBrowser: try: await _capture_with_browser(browser) + # 截图完成后立刻做一次可用性探测。 + # 在部分系统环境中,浏览器可能在任务完成后迅速断连,这里主动重建保证“常驻”语义。 + if not await self._is_browser_alive(browser, timeout_seconds=2.0): + logger.warning("[md2img] 截图后浏览器已断连,立即执行自动重建") + await self.restart_browser() except Exception as e: # 首次失败后重建一次浏览器再重试,提升抗偶发故障能力。 logger.warning(f"[md2img] 常驻浏览器截图失败,准备重建后重试: {e}")