Files
browser/bindings/python/lightpanda/client.py
Adrià Arrufat ed966e15ec python: kill the sidecar when its parent dies
PR_SET_PDEATHSIG on the spawned lightpanda mcp process (Linux) so a
SIGKILLed or crashed interpreter cannot leak a browser process — a
2h44m orphan surfaced during the v6 benchmark campaign. Regression
test kills a parent and asserts the sidecar exits.
2026-07-24 16:26:03 +02:00

202 lines
6.8 KiB
Python

"""Subprocess management and MCP-over-HTTP JSON-RPC transport.
The bundled ``lightpanda`` binary is spawned in ``mcp --port <n>`` mode
(MCP Streamable HTTP on 127.0.0.1). Each JSON-RPC message is one POST;
session routing uses the ``Mcp-Session-Id`` header — an unknown id creates
the session on first use, so the client mints its own ids. DELETE with the
header tears a session down.
"""
from __future__ import annotations
import json
import os
import shutil
import socket
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
from .errors import LightpandaError, ProtocolError
BINARY_NAME = "lightpanda.exe" if sys.platform == "win32" else "lightpanda"
_SPAWN_ATTEMPTS = 3
_READY_TIMEOUT = 15.0
def _die_with_parent():
"""Linux: ask the kernel to SIGTERM the sidecar when the parent dies,
so a SIGKILLed interpreter can't leak a browser process."""
try:
import ctypes
import signal as _signal
PR_SET_PDEATHSIG = 1
ctypes.CDLL(None, use_errno=True).prctl(PR_SET_PDEATHSIG, _signal.SIGTERM)
except Exception:
pass
def find_binary(explicit: str | os.PathLike | None = None) -> Path:
"""Locate the lightpanda binary: explicit arg, $LIGHTPANDA_BIN, the
bundled package copy, then PATH."""
candidates: list[Path] = []
if explicit:
candidates.append(Path(explicit))
env = os.environ.get("LIGHTPANDA_BIN")
if env:
candidates.append(Path(env))
candidates.append(Path(__file__).parent / BINARY_NAME)
which = shutil.which("lightpanda")
if which:
candidates.append(Path(which))
for candidate in candidates:
if candidate.is_file():
return candidate
raise LightpandaError(
"could not find the lightpanda binary; reinstall the package, set "
"LIGHTPANDA_BIN, or put `lightpanda` on PATH"
)
def _free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class Client:
"""Owns one lightpanda subprocess and speaks JSON-RPC to it."""
def __init__(
self,
binary: str | os.PathLike | None = None,
env: dict[str, str] | None = None,
timeout: float = 300.0,
verbose: bool = False,
args: tuple[str, ...] | list[str] = (),
):
self._binary = find_binary(binary)
self._extra_args = list(args)
self._timeout = timeout
self._lock = threading.Lock()
self._id = 0
self._proc: subprocess.Popen | None = None
self._port = 0
child_env = dict(os.environ)
if env:
child_env.update(env)
stderr = None if verbose else subprocess.DEVNULL
last_error: Exception | None = None
for _ in range(_SPAWN_ATTEMPTS):
port = _free_port()
proc = subprocess.Popen(
[str(self._binary), "mcp", "--port", str(port), *self._extra_args],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=stderr,
env=child_env,
preexec_fn=_die_with_parent if sys.platform == "linux" else None,
)
try:
self._wait_ready(proc, port)
except LightpandaError as err:
last_error = err
self._terminate(proc)
continue
self._proc = proc
self._port = port
return
raise LightpandaError(f"failed to start lightpanda: {last_error}")
@staticmethod
def _wait_ready(proc: subprocess.Popen, port: int) -> None:
deadline = time.monotonic() + _READY_TIMEOUT
while time.monotonic() < deadline:
if proc.poll() is not None:
raise LightpandaError(
f"lightpanda exited during startup (code {proc.returncode})"
)
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.25):
return
except OSError:
time.sleep(0.05)
raise LightpandaError("timed out waiting for lightpanda to listen")
@property
def base_url(self) -> str:
return f"http://127.0.0.1:{self._port}/"
def _http(self, method: str, body: bytes | None, session_id: str | None) -> tuple[int, bytes]:
headers = {"Content-Type": "application/json"}
if session_id:
headers["Mcp-Session-Id"] = session_id
req = urllib.request.Request(self.base_url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
return resp.status, resp.read()
except urllib.error.HTTPError as err:
return err.code, err.read()
except OSError as err:
alive = self._proc is not None and self._proc.poll() is None
state = "running" if alive else f"exited (code {self._proc.returncode})" if self._proc else "not started"
raise LightpandaError(f"lost connection to lightpanda ({state}): {err}") from err
def request(self, method: str, params: dict | None = None, session_id: str | None = None):
"""Send one JSON-RPC request and return its ``result``."""
with self._lock:
self._id += 1
rpc_id = self._id
message: dict = {"jsonrpc": "2.0", "id": rpc_id, "method": method}
if params is not None:
message["params"] = params
status, body = self._http("POST", json.dumps(message).encode(), session_id)
if not body:
raise ProtocolError(f"empty response (HTTP {status}) for {method}")
try:
payload = json.loads(body)
except ValueError as err:
raise ProtocolError(f"invalid JSON-RPC response for {method}: {body[:200]!r}") from err
if error := payload.get("error"):
raise ProtocolError(f"{error.get('message', 'error')} (code {error.get('code')})", code=error.get("code"))
return payload.get("result")
def notify(self, method: str, session_id: str | None = None) -> None:
message = {"jsonrpc": "2.0", "method": method}
self._http("POST", json.dumps(message).encode(), session_id)
def delete_session(self, session_id: str) -> None:
self._http("DELETE", None, session_id)
@staticmethod
def _terminate(proc: subprocess.Popen) -> None:
if proc.poll() is not None:
return
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
def close(self) -> None:
if self._proc is not None:
self._terminate(self._proc)
self._proc = None
def __del__(self):
try:
self.close()
except Exception:
pass