From 975340087c35ba43990ac7ef88644a9f9de79d20 Mon Sep 17 00:00:00 2001 From: iso2kx <8766057+iso2kx@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:18:14 +0800 Subject: [PATCH] fix(gateway): launch restart watcher windowless on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-update respawn chain has two process legs: the watcher that polls the old gateway PID, and the gateway it respawns. #52239 rewrote the gateway leg through windowless_gateway_restart_spec, but the watcher itself still launched via bare sys.executable — the venv's console python.exe. Under a uv venv that launcher re-execs the base console interpreter with default creationflags, a fresh CreateProcess that inherits none of the watcher's DETACHED / CREATE_NO_WINDOW flags, so a blank console window stays on screen for the whole drain window (up to ~2 minutes) after every GUI update. Route watcher_argv through the same windowless_gateway_restart_spec rewrite and thread the cwd + VIRTUAL_ENV/PYTHONPATH overlay into the watcher Popen so the base pythonw.exe can import hermes_cli and gateway.status without the venv launcher. Reproduced on Windows 11 (uv-managed venv, Desktop GUI auto-update): before the fix an empty python.exe console appears when the updater drains the old gateway and closes when the watcher exits; with the rewrite the whole chain is windowless. Same diagnosis as the reverted #53850, adapted to the current _spawn_gateway_restart_watcher structure. Fixes #51661. --- hermes_cli/gateway.py | 43 ++++++++++++-- tests/tools/test_windows_native_support.py | 67 ++++++++++++++++++++++ 2 files changed, 106 insertions(+), 4 deletions(-) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index dc988fa40f230..292e7fd016cd5 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -840,13 +840,49 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool: *run_argv, ] + # The watcher leg has the same console trap as the respawned gateway: + # ``sys.executable`` here is the venv's console ``python.exe``. Under a + # uv venv that launcher re-execs the *base* console interpreter — a + # fresh CreateProcess that inherits none of our DETACHED / + # CREATE_NO_WINDOW flags — so a blank visible console sits on screen for + # the whole drain window (it belongs to the watcher, not the gateway). + # Route the watcher through the same windowless rewrite as the gateway + # leg above; the env overlay lets the base ``pythonw.exe`` import + # ``hermes_cli`` / ``gateway.status`` without the venv launcher. + watcher_cwd = "" + watcher_env: dict[str, str] | None = None + if sys.platform == "win32": + try: + from hermes_cli.gateway_windows import ( + windowless_gateway_restart_spec, + ) + + watcher_argv, watcher_cwd, watcher_env_overlay = ( + windowless_gateway_restart_spec(watcher_argv) + ) + if watcher_env_overlay: + watcher_env = {**os.environ, **watcher_env_overlay} + except Exception: + # Best-effort, mirroring the gateway-leg fallback above: a + # visible watcher window beats a restart that never happens. + watcher_cwd = "" + watcher_env = None + + watcher_popen_kwargs: dict = { + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + } + if watcher_cwd: + watcher_popen_kwargs["cwd"] = watcher_cwd + if watcher_env is not None: + watcher_popen_kwargs["env"] = watcher_env + # Same platform-aware detach for the watcher process itself — so # closing the user's terminal doesn't kill the watcher. try: subprocess.Popen( watcher_argv, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + **watcher_popen_kwargs, **windows_detach_popen_kwargs(), ) except OSError: @@ -863,8 +899,7 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool: ) subprocess.Popen( watcher_argv, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + **watcher_popen_kwargs, **fallback_kwargs, ) except OSError: diff --git a/tests/tools/test_windows_native_support.py b/tests/tools/test_windows_native_support.py index 2e6606ead5b81..9ed3d6a42a4c0 100644 --- a/tests/tools/test_windows_native_support.py +++ b/tests/tools/test_windows_native_support.py @@ -1052,6 +1052,73 @@ def test_watcher_rewrites_console_python_to_windowless(self): "HERMES_HOME) so the windowless base pythonw resolves hermes_cli." ) + def test_watcher_process_itself_is_windowless(self): + """The watcher *process* must not launch via the venv's console + ``python.exe`` either. + + Same trap, other leg: ``watcher_argv`` led with bare + ``sys.executable``. Under a uv venv that console launcher re-execs + the base console interpreter with default creationflags — a fresh + CreateProcess that inherits none of the watcher's DETACHED / + CREATE_NO_WINDOW flags — so a blank console window stays visible for + the whole drain window even though the respawned gateway leg was + already rewritten to pythonw. + + Static check: the watcher spawn must route ``watcher_argv`` through + ``windowless_gateway_restart_spec`` and thread the resulting cwd / + env overlay into the watcher ``Popen``. + """ + root = Path(__file__).resolve().parents[2] + text = (root / "hermes_cli" / "gateway.py").read_text(encoding="utf-8") + start = text.find("watcher_argv = [") + assert start != -1 + block = text[start:] + assert "windowless_gateway_restart_spec(watcher_argv)" in block, ( + "_spawn_gateway_restart_watcher must rewrite watcher_argv via " + "windowless_gateway_restart_spec so the watcher runs under the " + "windowless base pythonw.exe, not the venv console python.exe." + ) + + def test_watcher_popen_carries_windowless_interpreter_and_overlay(self): + """Behavioral: on win32 the watcher ``Popen`` argv leads with the + windowless interpreter and carries the cwd + env overlay from + ``windowless_gateway_restart_spec``.""" + import hermes_cli.gateway as gateway_mod + import hermes_cli.gateway_windows as gw + + popen_calls: list[tuple[list[str], dict]] = [] + + def fake_spec(argv): + return ( + ["C:/base/pythonw.exe", *argv[1:]], + "C:/hermes", + {"VIRTUAL_ENV": "C:/venv", "PYTHONPATH": "C:/proj"}, + ) + + def fake_popen(argv, **kwargs): + popen_calls.append((argv, kwargs)) + return MagicMock() + + with mock.patch.object( + gw, "windowless_gateway_restart_spec", side_effect=fake_spec + ), mock.patch.object( + gateway_mod.subprocess, "Popen", side_effect=fake_popen + ), mock.patch.object(gateway_mod.sys, "platform", "win32"): + ok = gateway_mod._spawn_gateway_restart_watcher( + 1234, + ["C:/venv/Scripts/python.exe", "-m", "hermes_cli.main", "gateway", "run"], + ) + + assert ok is True + assert popen_calls, "watcher Popen was never invoked" + argv, kwargs = popen_calls[-1] + assert argv[0] == "C:/base/pythonw.exe", ( + "watcher must launch under the windowless base interpreter" + ) + assert kwargs.get("cwd") == "C:/hermes" + assert kwargs["env"]["VIRTUAL_ENV"] == "C:/venv" + assert "C:/proj" in kwargs["env"]["PYTHONPATH"] + class TestWindowlessGatewayRestartSpec: """gateway_windows.windowless_gateway_restart_spec — the helper that