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,
|
detect_xmlrpc_port,
|
||||||
find_free_port,
|
find_free_port,
|
||||||
is_port_available,
|
is_port_available,
|
||||||
|
patch_flowgraph,
|
||||||
patch_xmlrpc_port,
|
patch_xmlrpc_port,
|
||||||
)
|
)
|
||||||
from gnuradio_mcp.models import ContainerModel, ScreenshotModel
|
from gnuradio_mcp.models import ContainerModel, ScreenshotModel
|
||||||
@ -89,10 +90,15 @@ class DockerMiddleware:
|
|||||||
if enable_vnc:
|
if enable_vnc:
|
||||||
vnc_port_resolved = self._resolve_port(DEFAULT_VNC_PORT, "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)
|
embedded_port = detect_xmlrpc_port(fg_path)
|
||||||
if embedded_port is not None and embedded_port != xmlrpc_port:
|
port_to_patch = (
|
||||||
fg_path = patch_xmlrpc_port(fg_path, xmlrpc_port)
|
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(
|
logger.info(
|
||||||
"Patched flowgraph XML-RPC port: %d -> %d",
|
"Patched flowgraph XML-RPC port: %d -> %d",
|
||||||
embedded_port,
|
embedded_port,
|
||||||
|
|||||||
@ -20,6 +20,40 @@ logger = logging.getLogger(__name__)
|
|||||||
# GRC emits: xmlrpc_server_0 = SimpleXMLRPCServer(('localhost', 8080), ...)
|
# GRC emits: xmlrpc_server_0 = SimpleXMLRPCServer(('localhost', 8080), ...)
|
||||||
_XMLRPC_PORT_RE = re.compile(r"(SimpleXMLRPCServer\(\s*\([^,]+,\s*)(\d+)(\s*\))")
|
_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):
|
class PortConflictError(RuntimeError):
|
||||||
"""Raised when a requested port is already in use."""
|
"""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)
|
logger.debug("Patched flowgraph written to %s", tmp)
|
||||||
return 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
|
# Original file should be unchanged
|
||||||
assert "8080" in fg_file.read_text()
|
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
|
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 = tmp_path / "flowgraph.py"
|
||||||
fg_file.write_text(_SAMPLE_FG)
|
fg_file.write_text(_SAMPLE_FG)
|
||||||
|
|
||||||
@ -498,6 +498,13 @@ class TestPortAllocation:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result.xmlrpc_port == 8080
|
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"))
|
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