diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index fa3363d7e..62675672f 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -884,6 +884,44 @@ def tool_element_info(path: str) -> dict: } +def _launch_executable(src: Path, sandbox: Path, windows: bool) -> Path: + """The executable _run_qet() starts: a private copy, except on Windows. + + The copy gives each run its own SingleApplication key, which is derived + from the executable's path. On Windows a program loads its DLLs from its + own folder, so a copy on its own dies before main() (0xC0000135, DLL not + found) and nothing could ever be exported or edited there. Run the + original instead: every flag this server passes is a CLI export flag or + --run, and QElectroTech handles both and returns before it constructs + SingleApplication (main.cpp), so there is no instance to be handed to. + The copy stays elsewhere for builds from before that early return. + """ + if windows: + return src + exe = sandbox / f"qet-mcp-{os.getpid()}" + shutil.copy2(src, exe) + return exe + + +def _launch_env(env: dict, home: Path, windows: bool) -> dict: + """The environment _run_qet() starts QElectroTech in. + + A private HOME and XDG directories, and, except on Windows, Qt's + offscreen platform so no display is needed. The Windows packages ship + only the qwindows platform plugin: asked for "offscreen", Qt finds no + plugin and stops at a message box nobody can close, so every call hung + until its timeout. The export flags and --run open no window there, so + the default platform is what they need. + """ + env = dict(env, + HOME=str(home), + XDG_CONFIG_HOME=str(home / ".config"), + XDG_DATA_HOME=str(home / ".local" / "share")) + if not windows: + env["QT_QPA_PLATFORM"] = "offscreen" + return env + + def _run_qet(binary: str, args: list[str], timeout: int = 180, elements_dir: str | None = None, script: str | None = None, tail: int = 4000) -> dict: @@ -925,8 +963,7 @@ def _run_qet(binary: str, args: list[str], timeout: int = 180, raise ValueError(f"not an executable: {src}") with tempfile.TemporaryDirectory(prefix="qet-mcp-") as tmp: sandbox = Path(tmp) - exe = sandbox / f"qet-mcp-{os.getpid()}" - shutil.copy2(src, exe) + exe = _launch_executable(src, sandbox, os.name == "nt") home = sandbox / "home" (home / ".config").mkdir(parents=True) (home / ".local" / "share").mkdir(parents=True) @@ -943,11 +980,7 @@ def _run_qet(binary: str, args: list[str], timeout: int = 180, script_path = sandbox / "qet-mcp-edit.js" script_path.write_text(script, encoding="utf-8") args = ["--run", str(script_path), *args] - env = dict(os.environ, - HOME=str(home), - XDG_CONFIG_HOME=str(home / ".config"), - XDG_DATA_HOME=str(home / ".local" / "share"), - QT_QPA_PLATFORM="offscreen") + env = _launch_env(dict(os.environ), home, os.name == "nt") try: p = subprocess.run([str(exe), *args], env=env, timeout=timeout, capture_output=True, text=True) diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index 24c1e2718..3c1815ac0 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -1902,6 +1902,45 @@ class PathPolicy(unittest.TestCase): f"{name} has no {arg!r} argument to guard") +class LaunchExecutable(unittest.TestCase): + """Windows cannot run a lone copy of QElectroTech (F065): its DLLs sit + beside the original. Everywhere else the private copy stays.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + base = Path(self.tmp.name) + self.src = base / "bin" / "qelectrotech" + self.src.parent.mkdir() + self.src.write_text("#!/bin/sh\nexit 0\n") + self.sandbox = base / "sandbox" + self.sandbox.mkdir() + + def tearDown(self): + self.tmp.cleanup() + + def test_windows_runs_the_original(self): + self.assertEqual(m._launch_executable(self.src, self.sandbox, True), self.src) + self.assertEqual(list(self.sandbox.iterdir()), [], "nothing is copied on Windows") + + def test_windows_keeps_its_own_qt_platform(self): + """The Windows packages have no offscreen plugin; asking for it + leaves QElectroTech stuck at a message box.""" + env = m._launch_env({"PATH": "x"}, self.sandbox, True) + self.assertNotIn("QT_QPA_PLATFORM", env) + self.assertEqual(env["PATH"], "x") + + def test_elsewhere_runs_offscreen(self): + env = m._launch_env({}, self.sandbox, False) + self.assertEqual(env["QT_QPA_PLATFORM"], "offscreen") + self.assertEqual(env["HOME"], str(self.sandbox)) + + def test_elsewhere_runs_a_private_copy(self): + exe = m._launch_executable(self.src, self.sandbox, False) + self.assertEqual(exe.parent, self.sandbox) + self.assertNotEqual(exe, self.src) + self.assertEqual(exe.read_text(), self.src.read_text()) + + class ScriptingDisabledHint(unittest.TestCase): """QElectroTech may refuse to run scripts at all, and says so in French.