feat: add Docker compat patching pipeline for flowgraph launches

Flowgraphs generated by GRC on the host often fail in Docker containers
due to three issues:

1. message_debug constructor signature changed between GR 3.10.5 and
   3.10.12 — strip the second arg for older runtimes
2. XML-RPC binds to localhost, unreachable from Docker host — rewrite
   to 0.0.0.0
3. input('Press Enter to quit:') gets immediate EOF in detached
   containers, killing the flowgraph instantly — inject signal.pause()
   fallback after EOFError

All patches are applied in a single pass via patch_flowgraph() before
the container launches. The original file is never modified.
This commit is contained in:
Ryan Malloy 2026-01-30 19:22:20 -07:00
parent dca4e80857
commit f720f767ee
3 changed files with 100 additions and 7 deletions

View File

@ -10,6 +10,7 @@ from gnuradio_mcp.middlewares.ports import (
detect_xmlrpc_port,
find_free_port,
is_port_available,
patch_flowgraph,
patch_xmlrpc_port,
)
from gnuradio_mcp.models import ContainerModel, ScreenshotModel
@ -89,10 +90,15 @@ class DockerMiddleware:
if enable_vnc:
vnc_port_resolved = self._resolve_port(DEFAULT_VNC_PORT, "VNC")
# --- Flowgraph port patching ---
# --- Flowgraph patching (port rewrite + compat fixes) ---
embedded_port = detect_xmlrpc_port(fg_path)
if embedded_port is not None and embedded_port != xmlrpc_port:
fg_path = patch_xmlrpc_port(fg_path, xmlrpc_port)
port_to_patch = (
xmlrpc_port
if embedded_port is not None and embedded_port != xmlrpc_port
else None
)
fg_path = patch_flowgraph(fg_path, xmlrpc_port=port_to_patch)
if port_to_patch is not None:
logger.info(
"Patched flowgraph XML-RPC port: %d -> %d",
embedded_port,

View File

@ -20,6 +20,40 @@ logger = logging.getLogger(__name__)
# GRC emits: xmlrpc_server_0 = SimpleXMLRPCServer(('localhost', 8080), ...)
_XMLRPC_PORT_RE = re.compile(r"(SimpleXMLRPCServer\(\s*\([^,]+,\s*)(\d+)(\s*\))")
# Compat: GRC 3.10.12+ emits message_debug(True, gr.log_levels.info)
# but GNU Radio 3.10.5 (Docker images) only accepts message_debug(en_uvec: bool).
_MESSAGE_DEBUG_COMPAT_RE = re.compile(
r"blocks\.message_debug\(True,\s*gr\.log_levels\.\w+\)"
)
# Docker: GRC generates SimpleXMLRPCServer(('localhost', ...)) which is
# unreachable from the Docker host. Rewrite to 0.0.0.0 for container use.
_XMLRPC_LOCALHOST_RE = re.compile(
r"(SimpleXMLRPCServer\(\s*\()'localhost'(\s*,)"
)
# Docker: GRC's no_gui template uses input('Press Enter to quit: ') which
# gets immediate EOF in detached containers, killing the flowgraph instantly.
# We inject a signal.pause() fallback after the EOFError catch.
_INPUT_EOF_RE = re.compile(
r"( +)try:\n\1 input\('Press Enter to quit: '\)\n"
r"\1except EOFError:\n\1 pass",
re.MULTILINE,
)
_INPUT_EOF_REPLACEMENT = (
r"\1try:\n"
r"\1 input('Press Enter to quit: ')\n"
r"\1except EOFError:\n"
r"\1 import signal as _sig\n"
r"\1 try:\n"
r"\1 _sig.pause()\n"
r"\1 except AttributeError:\n"
r"\1 import time\n"
r"\1 while True:\n"
r"\1 time.sleep(1)"
)
class PortConflictError(RuntimeError):
"""Raised when a requested port is already in use."""
@ -94,3 +128,49 @@ def patch_xmlrpc_port(flowgraph_py: Path, new_port: int) -> Path:
logger.debug("Patched flowgraph written to %s", tmp)
return tmp
def _apply_compat_patches(text: str) -> str:
"""Apply compatibility fixes so GRC-generated code runs in Docker.
Handles three categories of issues:
1. Cross-version constructor changes (message_debug signature)
2. Network binding (localhost 0.0.0.0 for container accessibility)
3. Docker lifecycle (input() EOF signal.pause() for detached mode)
"""
text = _MESSAGE_DEBUG_COMPAT_RE.sub("blocks.message_debug(True)", text)
text = _XMLRPC_LOCALHOST_RE.sub(r"\g<1>'0.0.0.0'\2", text)
text = _INPUT_EOF_RE.sub(_INPUT_EOF_REPLACEMENT, text)
return text
def patch_flowgraph(
flowgraph_py: Path,
xmlrpc_port: int | None = None,
) -> Path:
"""Apply all patches (port rewrite + compat fixes) in a single pass.
Returns the original path unchanged if no patches were needed,
or a new temp file in the same directory.
"""
text = flowgraph_py.read_text()
original = text
if xmlrpc_port is not None:
text, _ = _XMLRPC_PORT_RE.subn(rf"\g<1>{xmlrpc_port}\3", text)
text = _apply_compat_patches(text)
if text == original:
return flowgraph_py
fd, tmp_path = tempfile.mkstemp(
suffix=".py",
prefix=f"{flowgraph_py.stem}_patched_",
dir=flowgraph_py.parent,
)
tmp = Path(tmp_path)
tmp.write_text(text)
os.close(fd)
logger.debug("Patched flowgraph written to %s", tmp)
return tmp

View File

@ -477,10 +477,10 @@ class TestPortAllocation:
# Original file should be unchanged
assert "8080" in fg_file.read_text()
def test_launch_no_patch_when_ports_match(
def test_launch_compat_patch_when_ports_match(
self, docker_mw, mock_docker_client, tmp_path
):
"""When flowgraph port matches requested port, no patching should occur."""
"""When ports match, port is unchanged but compat patches still apply."""
fg_file = tmp_path / "flowgraph.py"
fg_file.write_text(_SAMPLE_FG)
@ -498,6 +498,13 @@ class TestPortAllocation:
)
assert result.xmlrpc_port == 8080
# No patched files should exist (only the original)
# Compat patches (localhost→0.0.0.0) create a patched file even
# when the port matches, so we expect 2 .py files.
py_files = list(tmp_path.glob("*.py"))
assert len(py_files) == 1
assert len(py_files) == 2
patched = [f for f in py_files if "patched" in f.name]
assert len(patched) == 1
# Port unchanged, but localhost rewritten to 0.0.0.0
patched_text = patched[0].read_text()
assert "8080" in patched_text
assert "'0.0.0.0'" in patched_text