From 06fc5153c1aaafa659b727120252d541f67e7a18 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 14:25:06 +1300 Subject: [PATCH] qet-mcp: make QElectroTech launches work on Windows On Windows every tool that starts QElectroTech (qet_export, qet_edit, qet_query, qet_check, qet_continuity, qet_project_new) failed, for two reasons: - The server ran a copy of the executable from a temporary folder, to get its own SingleApplication key. A Windows program loads its DLLs from its own folder, so the copy died before main() with 0xC0000135 (DLL not found). Every flag the server passes is a CLI export flag or --run, and main.cpp handles both before it constructs SingleApplication, so on Windows the original is now run. The copy stays elsewhere. - It set QT_QPA_PLATFORM=offscreen. The Windows packages ship only the qwindows platform plugin, so Qt found none and stopped at a message box nobody could close: every call hung until its timeout. Windows now keeps its default platform; the export flags and --run open no window. Checked under Wine (qet-wine-smoke) on the fork's CI Windows build, run through python.org's embeddable Python: before, qet_export ended with exit 3221225781; after, a PDF export, a qet_query (98 elements, as on Linux) and a qet_edit placing a common:// element all succeed. The hang was isolated by launching the same export from bash (works) and from Python with one change at a time: only dropping QT_QPA_PLATFORM made it work. Four unit tests pin both choices per platform; each fails with its fix removed. Suite 262/262 none skipped on Linux. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/qet_mcp.py | 47 ++++++++++++++++++++++++++++++------ misc/qet-mcp/test_qet_mcp.py | 39 ++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 7 deletions(-) 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.