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:
parent
dca4e80857
commit
f720f767ee
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user