Ryan Malloy bfab802e05 coverage: add cross-process coverage collection for containerized flowgraphs
New MCP tools:
- collect_coverage(name) - combine parallel files, return summary
- generate_coverage_report(name, format) - HTML/XML/JSON reports
- combine_coverage(names) - aggregate across test runs
- delete_coverage(name?, older_than_days?) - cleanup

Modified:
- launch_flowgraph() now accepts enable_coverage parameter
- stop() uses 30s timeout for graceful shutdown (coverage needs atexit)

Docker:
- Dockerfile.gnuradio-coverage extends runtime with python3-coverage
- entrypoint-coverage.sh wraps execution with coverage run
- .coveragerc configured for GNU Radio source paths

Tests: 125 unit tests (21 new), 80% coverage
2026-01-27 13:50:17 -07:00

223 lines
7.8 KiB
Python

from __future__ import annotations
import base64
import logging
from pathlib import Path
from typing import Any
from gnuradio_mcp.models import ContainerModel, ScreenshotModel
logger = logging.getLogger(__name__)
DEFAULT_XMLRPC_PORT = 8080
DEFAULT_VNC_PORT = 5900
DEFAULT_STOP_TIMEOUT = 30 # Seconds to wait for graceful shutdown (coverage needs time)
RUNTIME_IMAGE = "gnuradio-runtime:latest"
COVERAGE_IMAGE = "gnuradio-coverage:latest"
CONTAINER_FLOWGRAPH_DIR = "/flowgraphs"
CONTAINER_COVERAGE_DIR = "/coverage"
HOST_COVERAGE_BASE = "/tmp/gr-coverage"
class DockerMiddleware:
"""Wraps the Docker SDK to manage GNU Radio runtime containers.
Each container runs a flowgraph with Xvfb for headless QT rendering.
XML-RPC is exposed for variable control; VNC is optional for visual debugging.
"""
def __init__(self, docker_client: Any):
self._client = docker_client
@classmethod
def create(cls) -> DockerMiddleware | None:
"""Attempt to create a DockerMiddleware. Returns None if Docker is unavailable."""
try:
import docker
client = docker.from_env()
client.ping()
return cls(client)
except Exception as e:
logger.warning("Docker unavailable: %s", e)
return None
def launch(
self,
flowgraph_path: str,
name: str,
xmlrpc_port: int = DEFAULT_XMLRPC_PORT,
enable_vnc: bool = False,
enable_coverage: bool = False,
device_paths: list[str] | None = None,
) -> ContainerModel:
"""Launch a flowgraph in a Docker container with Xvfb.
Args:
flowgraph_path: Path to the .py flowgraph file
name: Container name
xmlrpc_port: Port for XML-RPC variable control
enable_vnc: Enable VNC server for visual debugging
enable_coverage: Use coverage image and collect Python coverage data
device_paths: Host device paths to pass through (e.g., /dev/ttyUSB0)
"""
fg_path = Path(flowgraph_path).resolve()
if not fg_path.exists():
raise FileNotFoundError(f"Flowgraph not found: {fg_path}")
# Select image based on coverage mode
image = COVERAGE_IMAGE if enable_coverage else RUNTIME_IMAGE
env = {"DISPLAY": ":99", "XMLRPC_PORT": str(xmlrpc_port)}
if enable_vnc:
env["ENABLE_VNC"] = "1"
if enable_coverage:
env["ENABLE_COVERAGE"] = "1"
ports: dict[str, int] = {f"{xmlrpc_port}/tcp": xmlrpc_port}
vnc_port: int | None = None
if enable_vnc:
vnc_port = DEFAULT_VNC_PORT
ports[f"{vnc_port}/tcp"] = vnc_port
volumes = {
str(fg_path.parent): {
"bind": CONTAINER_FLOWGRAPH_DIR,
"mode": "ro",
}
}
# Mount coverage directory if coverage enabled
if enable_coverage:
coverage_dir = Path(HOST_COVERAGE_BASE) / name
coverage_dir.mkdir(parents=True, exist_ok=True)
volumes[str(coverage_dir)] = {
"bind": CONTAINER_COVERAGE_DIR,
"mode": "rw",
}
devices = [f"{d}:{d}:rwm" for d in (device_paths or [])]
container_fg_path = f"{CONTAINER_FLOWGRAPH_DIR}/{fg_path.name}"
container = self._client.containers.run(
image,
command=["python3", container_fg_path],
name=name,
detach=True,
environment=env,
ports=ports,
volumes=volumes,
devices=devices or None,
labels={
"gr-mcp": "true",
"gr-mcp.flowgraph": str(fg_path),
"gr-mcp.xmlrpc-port": str(xmlrpc_port),
"gr-mcp.vnc-enabled": "1" if enable_vnc else "0",
"gr-mcp.coverage-enabled": "1" if enable_coverage else "0",
},
)
return ContainerModel(
name=name,
container_id=container.id[:12],
status="running",
flowgraph_path=str(fg_path),
xmlrpc_port=xmlrpc_port,
vnc_port=vnc_port,
device_paths=device_paths or [],
coverage_enabled=enable_coverage,
)
def list_containers(self) -> list[ContainerModel]:
"""List all gr-mcp managed containers."""
containers = self._client.containers.list(
all=True, filters={"label": "gr-mcp=true"}
)
result = []
for c in containers:
labels = c.labels
result.append(
ContainerModel(
name=c.name,
container_id=c.id[:12],
status=c.status,
flowgraph_path=labels.get("gr-mcp.flowgraph", ""),
xmlrpc_port=int(labels.get("gr-mcp.xmlrpc-port", DEFAULT_XMLRPC_PORT)),
vnc_port=DEFAULT_VNC_PORT
if labels.get("gr-mcp.vnc-enabled") == "1" and c.status == "running"
else None,
coverage_enabled=labels.get("gr-mcp.coverage-enabled") == "1",
)
)
return result
def stop(self, name: str, timeout: int = DEFAULT_STOP_TIMEOUT) -> bool:
"""Stop a container gracefully with SIGTERM.
Uses a longer timeout (30s) to allow coverage data to be flushed.
Falls back to SIGKILL if container doesn't respond, but warns that
coverage data may be lost.
Args:
name: Container name
timeout: Seconds to wait for graceful shutdown before SIGKILL
"""
container = self._client.containers.get(name)
try:
container.stop(timeout=timeout)
except Exception as e:
# Timeout reached, container will be killed - coverage may be lost
logger.warning(
"Container %s didn't stop gracefully within %ds, "
"coverage data may be lost: %s",
name, timeout, e
)
return True
def remove(self, name: str, force: bool = False) -> bool:
"""Remove a container by name."""
container = self._client.containers.get(name)
container.remove(force=force)
return True
def get_logs(self, name: str, tail: int = 100) -> str:
"""Get container logs."""
container = self._client.containers.get(name)
return container.logs(tail=tail).decode("utf-8", errors="replace")
def capture_screenshot(self, name: str) -> ScreenshotModel:
"""Capture the Xvfb framebuffer via ImageMagick import."""
container = self._client.containers.get(name)
exit_code, output = container.exec_run(
["import", "-display", ":99", "-window", "root", "png:-"],
)
if exit_code != 0:
raise RuntimeError(
f"Screenshot failed (exit {exit_code}): "
f"{output.decode('utf-8', errors='replace')[:200]}"
)
image_b64 = base64.b64encode(output).decode("ascii")
return ScreenshotModel(
container_name=name,
image_base64=image_b64,
format="png",
)
def get_xmlrpc_port(self, name: str) -> int:
"""Get the XML-RPC port for a container."""
container = self._client.containers.get(name)
return int(
container.labels.get("gr-mcp.xmlrpc-port", DEFAULT_XMLRPC_PORT)
)
def is_coverage_enabled(self, name: str) -> bool:
"""Check if coverage is enabled for a container."""
container = self._client.containers.get(name)
return container.labels.get("gr-mcp.coverage-enabled") == "1"
def get_coverage_dir(self, name: str) -> Path:
"""Get the host-side coverage directory for a container."""
return Path(HOST_COVERAGE_BASE) / name