From af52cf674a948156ff671f008cbe94cdd7bc0b6a Mon Sep 17 00:00:00 2001 From: jonathanzhan1975 <160553727+jonathanzhan1975@users.noreply.github.com> Date: Wed, 6 May 2026 11:40:29 +0800 Subject: [PATCH] fix: Windows Playwright HTML rendering under Streamlit Streamlit/Tornado switches the asyncio policy to WindowsSelectorEventLoopPolicy on Windows, which breaks Playwright subprocess creation. Fixed by using an explicit ProactorEventLoop on Windows, and tracking the event loop per browser instance to detect cross-loop reuse. --- pixelle_video/services/frame_html.py | 27 ++++++++++++++++++++++++--- web/utils/async_helpers.py | 18 ++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/pixelle_video/services/frame_html.py b/pixelle_video/services/frame_html.py index b480c92..2eed3b5 100644 --- a/pixelle_video/services/frame_html.py +++ b/pixelle_video/services/frame_html.py @@ -25,6 +25,7 @@ Linux Environment Requirements: Playwright browser install: playwright install --with-deps chromium """ +import asyncio import os import re import tempfile @@ -55,6 +56,7 @@ class HTMLFrameGenerator: _browser = None _playwright = None + _browser_loop = None def __init__(self, template_path: str): """ @@ -307,7 +309,22 @@ class HTMLFrameGenerator: @classmethod async def _ensure_browser(cls): """Lazily initialize a shared Playwright browser instance""" - if cls._browser is None or not cls._browser.is_connected(): + current_loop = asyncio.get_running_loop() + browser_usable = ( + cls._browser is not None + and cls._browser_loop is current_loop + and cls._browser.is_connected() + ) + + if not browser_usable: + if cls._browser is not None and cls._browser_loop is not current_loop: + logger.warning( + "Detected cross-loop Playwright browser reuse attempt; " + "recreating browser for current event loop" + ) + + cls._browser = None + cls._playwright = None from playwright.async_api import async_playwright cls._playwright = await async_playwright().start() cls._browser = await cls._playwright.chromium.launch( @@ -318,6 +335,7 @@ class HTMLFrameGenerator: '--disable-extensions', ] ) + cls._browser_loop = current_loop logger.debug("Initialized Playwright Chromium browser") return cls._browser @@ -327,6 +345,7 @@ class HTMLFrameGenerator: if cls._browser: await cls._browser.close() cls._browser = None + cls._browser_loop = None if cls._playwright: await cls._playwright.stop() cls._playwright = None @@ -410,5 +429,7 @@ class HTMLFrameGenerator: return output_path except Exception as e: - logger.error(f"Failed to render HTML template: {e}") - raise RuntimeError(f"HTML rendering failed: {e}") + logger.exception("Failed to render HTML template") + raise RuntimeError( + f"HTML rendering failed: {type(e).__name__}: {e}" + ) from e diff --git a/web/utils/async_helpers.py b/web/utils/async_helpers.py index b755c99..f1b2d8b 100644 --- a/web/utils/async_helpers.py +++ b/web/utils/async_helpers.py @@ -15,6 +15,7 @@ Async helper functions for web UI """ import asyncio +import sys import tomllib from pathlib import Path @@ -23,6 +24,23 @@ from loguru import logger def run_async(coro): """Run async coroutine in sync context""" + if sys.platform == "win32": + # Streamlit/Tornado may switch the global asyncio policy to + # WindowsSelectorEventLoopPolicy, which breaks subprocess-based + # libraries such as Playwright on Windows. Use an explicit + # Proactor loop here so this sync bridge does not depend on the + # ambient global policy. + loop = asyncio.ProactorEventLoop() + try: + return loop.run_until_complete(coro) + finally: + try: + from pixelle_video.services.frame_html import HTMLFrameGenerator + + loop.run_until_complete(HTMLFrameGenerator.close_browser()) + except Exception as e: + logger.debug(f"Failed to cleanup HTML frame browser before loop close: {e}") + loop.close() return asyncio.run(coro)