From f720f767ee8833466ea901eda9ab78174bdb0bfb Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Fri, 30 Jan 2026 19:22:20 -0700 Subject: [PATCH] feat: add Docker compat patching pipeline for flowgraph launches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/gnuradio_mcp/middlewares/docker.py | 12 +++- src/gnuradio_mcp/middlewares/ports.py | 80 ++++++++++++++++++++++++++ tests/unit/test_docker_middleware.py | 15 +++-- 3 files changed, 100 insertions(+), 7 deletions(-) diff --git a/src/gnuradio_mcp/middlewares/docker.py b/src/gnuradio_mcp/middlewares/docker.py index f30c21b..90c46ad 100644 --- a/src/gnuradio_mcp/middlewares/docker.py +++ b/src/gnuradio_mcp/middlewares/docker.py @@ -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, diff --git a/src/gnuradio_mcp/middlewares/ports.py b/src/gnuradio_mcp/middlewares/ports.py index b680d10..ff701cf 100644 --- a/src/gnuradio_mcp/middlewares/ports.py +++ b/src/gnuradio_mcp/middlewares/ports.py @@ -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 diff --git a/tests/unit/test_docker_middleware.py b/tests/unit/test_docker_middleware.py index 9f48021..df18510 100644 --- a/tests/unit/test_docker_middleware.py +++ b/tests/unit/test_docker_middleware.py @@ -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