canopy-258m-r3 / miniswardbower /browser /lightpanda_driver.py
psikosen's picture
Update to v6: Tri-Engine Swarm Coordinator, URL Invariance, Thought Bus & State Verification
ed79a7f verified
Raw History Blame Contribute Delete
6.55 kB
"""
Lightpanda Headless CDP Engine for Miniswardbower.
Integrates lightpanda-io/browser (AI-native headless browser in Zig)
via Chrome DevTools Protocol (CDP) WebSocket endpoint.
Bypasses GPU rasterization and layout overhead for 9x-60x faster
DOM interactions, ultra-low memory (<30MB RAM), and instant startup (<15ms).
"""
from __future__ import annotations
import asyncio
import os
import socket
import subprocess
import tempfile
import time
from pathlib import Path
from typing import Optional, Tuple
from playwright.async_api import Browser, BrowserContext, Page, Playwright, async_playwright
class LightpandaServerManager:
"""Manages the background lifecycle of the Lightpanda CDP server process."""
def __init__(
self,
bin_path: Optional[str] = None,
host: str = "127.0.0.1",
port: Optional[int] = None,
log_level: str = "warn",
):
self.bin_path = bin_path or "/home/psikosen/canopy/bin/lightpanda"
self.host = host
self.port = port
self.log_level = log_level
self._process: Optional[subprocess.Popen] = None
self._cdp_ws_url: Optional[str] = None
self._log = None
def command(self) -> list[str]:
return [str(self.bin_path), "serve", "--host", self.host,
"--port", str(self.port), "--log-level", self.log_level]
@staticmethod
def find_free_port() -> int:
"""Finds an available TCP port assigned by the OS kernel."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def is_available(self) -> bool:
"""Checks if the lightpanda binary exists and is executable."""
path = Path(self.bin_path)
return path.exists() and os.access(str(path), os.X_OK)
async def start(self) -> str:
"""
Starts the lightpanda serve process and waits until the CDP endpoint is responsive.
Returns the WebSocket CDP endpoint URL (e.g. ws://127.0.0.1:9222).
"""
if not self.is_available():
raise FileNotFoundError(f"CDP browser binary not found or not executable at: {self.bin_path}")
if self.port is None:
self.port = self.find_free_port()
# A file avoids blocking the child on a full, unread stderr pipe.
self._log = tempfile.TemporaryFile(mode="w+")
self._process = subprocess.Popen(
self.command(),
stdout=self._log,
stderr=self._log,
text=True,
start_new_session=True,
)
# Wait for socket to become connectable
self._cdp_ws_url = f"ws://{self.host}:{self.port}"
connected = False
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
if self._process.poll() is not None:
self._log.seek(0)
err = self._log.read()[-4000:]
code = self._process.returncode
self.stop()
raise RuntimeError(f"CDP browser exited with code {code}: {err}")
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(0.2)
if s.connect_ex((self.host, self.port)) == 0:
connected = True
break
except Exception:
pass
await asyncio.sleep(0.02)
if not connected:
self.stop()
raise TimeoutError(f"Timed out waiting for CDP browser at {self._cdp_ws_url}")
return self._cdp_ws_url
def stop(self) -> None:
"""Stops the lightpanda server process gracefully."""
if self._process is not None:
try:
self._process.terminate()
self._process.wait(timeout=2.0)
except Exception:
try:
self._process.kill()
self._process.wait(timeout=2.0)
except Exception:
pass
finally:
self._process = None
if self._log is not None:
self._log.close()
self._log = None
def get_memory_rss_mb(self) -> float:
"""Returns the resident set size (RSS) memory of the Lightpanda daemon in MB."""
if self._process is None or self._process.poll() is not None:
return 0.0
try:
with open(f"/proc/{self._process.pid}/statm", "r") as f:
pages = int(f.read().split()[1])
page_size = os.sysconf("SC_PAGE_SIZE")
return (pages * page_size) / (1024 * 1024)
except Exception:
return 0.0
class LightpandaDriver:
"""
Playwright CDP Driver connected to Lightpanda.
Provides standard Playwright browser and page interfaces.
"""
def __init__(self, bin_path: Optional[str] = None):
self.server_mgr = LightpandaServerManager(bin_path=bin_path)
self._playwright: Optional[Playwright] = None
self._browser: Optional[Browser] = None
self._context: Optional[BrowserContext] = None
self._page: Optional[Page] = None
async def start(self) -> Tuple[Browser, BrowserContext, Page]:
"""Launches Lightpanda daemon and connects Playwright via connect_over_cdp."""
try:
ws_url = await self.server_mgr.start()
self._playwright = await async_playwright().start()
self._browser = await self._playwright.chromium.connect_over_cdp(ws_url, timeout=10000)
self._page = await self._browser.new_page()
self._context = self._page.context
return self._browser, self._context, self._page
except BaseException:
await self.stop()
raise
async def stop(self) -> None:
"""Closes Playwright connection and terminates Lightpanda server."""
try:
if self._page is not None:
await self._page.close()
except Exception:
pass
try:
if self._browser is not None:
await self._browser.close()
except Exception:
pass
try:
if self._playwright is not None:
await self._playwright.stop()
except Exception:
pass
self.server_mgr.stop()
self._page = None
self._context = None
self._browser = None
self._playwright = None