diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 3181751005a..652c93c7496 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -5,6 +5,7 @@ Handles: hermes gateway [run|start|stop|restart|status|install|uninstall|setup] """ import asyncio +import json import logging import os import shlex @@ -723,6 +724,38 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool: windows_detach_popen_kwargs, ) + # On Windows the incoming ``run_argv`` leads with the venv's console + # ``python.exe`` (from ``get_python_path()``). Respawning the gateway + # with that interpreter — even under CREATE_NO_WINDOW — leaves a + # persistent console window, because uv's venv launcher re-execs the + # base console interpreter, which allocates its own conhost. Rewrite + # the argv to the windowless ``pythonw.exe`` (mirroring the clean-start + # ``_spawn_detached`` path) and capture the cwd + env overlay the base + # interpreter needs to resolve imports without the venv launcher. + # No-op on POSIX. See gateway_windows.windowless_gateway_restart_spec. + respawn_cwd = "" + respawn_env_overlay: dict[str, str] = {} + if sys.platform == "win32": + try: + from hermes_cli.gateway_windows import ( + windowless_gateway_restart_spec, + ) + + run_argv, respawn_cwd, respawn_env_overlay = ( + windowless_gateway_restart_spec(list(run_argv)) + ) + except Exception: + # Best-effort: if the rewrite fails for any reason, fall back to + # the original argv. A visible window is worse than nothing, but + # a failed respawn is worse still — keep the gateway coming back. + respawn_cwd = "" + respawn_env_overlay = {} + + # Serialized as JSON literals embedded in the watcher source so the + # inner respawn can apply cwd= / env= without extra argv plumbing. + respawn_cwd_literal = json.dumps(respawn_cwd) + respawn_env_literal = json.dumps(respawn_env_overlay) + watcher = textwrap.dedent( """ import os @@ -736,6 +769,8 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool: pid = int(sys.argv[1]) cmd = sys.argv[2:] + _respawn_cwd = {respawn_cwd_literal} + _respawn_env_overlay = {respawn_env_literal} deadline = time.monotonic() + 120 while time.monotonic() < deadline: # ``os.kill(pid, 0)`` is not a no-op on Windows — use the @@ -752,10 +787,18 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool: # been spawned inside a job object (Electron/Tauri parent), and # without breakaway the respawned gateway would die when that job # tears down. See _subprocess_compat.windows_detach_flags(). - _popen_kwargs = { + _popen_kwargs = {{ "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, - } + }} + # Anchor the respawned gateway at the stable working dir and overlay + # the env (VIRTUAL_ENV / PYTHONPATH / HERMES_HOME) the windowless + # base interpreter needs to import hermes_cli. Empty on POSIX, where + # the venv python resolves imports without help. + if _respawn_cwd: + _popen_kwargs["cwd"] = _respawn_cwd + if _respawn_env_overlay: + _popen_kwargs["env"] = {{**os.environ, **_respawn_env_overlay}} if sys.platform == "win32": try: _popen_kwargs["creationflags"] = windows_detach_flags() @@ -772,7 +815,10 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool: _popen_kwargs["start_new_session"] = True subprocess.Popen(cmd, **_popen_kwargs) """ - ).strip() + ).strip().format( + respawn_cwd_literal=respawn_cwd_literal, + respawn_env_literal=respawn_env_literal, + ) watcher_argv = [ sys.executable, diff --git a/hermes_cli/gateway_windows.py b/hermes_cli/gateway_windows.py index c7959889523..55ed976433d 100644 --- a/hermes_cli/gateway_windows.py +++ b/hermes_cli/gateway_windows.py @@ -809,6 +809,77 @@ def _build_gateway_argv() -> tuple[list[str], str, dict[str, str]]: return argv, working_dir, env_overlay +def windowless_gateway_restart_spec( + run_argv: list[str], +) -> tuple[list[str], str, dict[str, str]]: + """Rewrite a console-``python.exe`` gateway argv into a windowless one. + + The post-update restart paths build their respawn command from + ``get_python_path()`` which returns the venv's console ``python.exe``. + On Windows — especially with uv-created venvs — launching that + interpreter (even with ``CREATE_NO_WINDOW``) leaves a persistent + console window: ``venv\\Scripts\\python.exe`` is a launcher shim that + re-execs the *base* console interpreter, which allocates its own + conhost. ``CREATE_NO_WINDOW`` cannot suppress that second window. + See ``_resolve_detached_python`` for the gory details. + + This mirrors what ``_build_gateway_argv`` / ``_spawn_detached`` do for + a clean start: swap the interpreter for the windowless ``pythonw.exe`` + (base interpreter for uv venvs) and return the cwd + env overlay + (VIRTUAL_ENV, PYTHONPATH) the base interpreter needs to resolve the + ``hermes_cli`` package without the venv launcher's site config. + + Returns ``(new_argv, working_dir, env_overlay)``. ``new_argv`` + preserves every argument after the interpreter (``-m hermes_cli.main + [--profile X] gateway run [--replace]``) verbatim. On non-Windows, or + if ``run_argv`` doesn't start with a resolvable python, the argv is + returned unchanged with an empty overlay. + """ + if not run_argv: + return run_argv, "", {} + if sys.platform != "win32": + return run_argv, "", {} + + from hermes_cli.config import get_hermes_home + from hermes_cli.gateway import PROJECT_ROOT + + python_exe = run_argv[0] + rest = run_argv[1:] + + # Only rewrite when the leading token actually looks like a python + # interpreter we can find a windowless sibling for. If a caller passed + # something else (a captured argv whose argv[0] is already pythonw, or a + # non-python launcher), leave it alone. + try: + windowless_python, venv_dir, extra_pythonpath = _resolve_detached_python( + python_exe + ) + except Exception: + return run_argv, "", {} + + new_argv = [windowless_python, *rest] + + working_dir = _stable_gateway_working_dir(PROJECT_ROOT) + project_root = str(PROJECT_ROOT) + try: + hermes_home = str(Path(get_hermes_home()).resolve()) + except Exception: + hermes_home = "" + + env_overlay: dict[str, str] = { + "PYTHONIOENCODING": "utf-8", + "HERMES_GATEWAY_DETACHED": "1", + "VIRTUAL_ENV": str(venv_dir), + } + if hermes_home: + env_overlay["HERMES_HOME"] = hermes_home + _prepend_pythonpath( + env_overlay, + [project_root, *extra_pythonpath] if extra_pythonpath else [project_root], + ) + return new_argv, working_dir, env_overlay + + def _spawn_detached(script_path: Path | None = None) -> int: """Launch the gateway as a fully detached background process. diff --git a/tests/tools/test_windows_native_support.py b/tests/tools/test_windows_native_support.py index 6f31074042b..2e6606ead5b 100644 --- a/tests/tools/test_windows_native_support.py +++ b/tests/tools/test_windows_native_support.py @@ -15,6 +15,7 @@ import os import signal import sys from pathlib import Path +from unittest import mock from unittest.mock import MagicMock import pytest @@ -1008,3 +1009,116 @@ class TestGatewayDetachedWatcherWindowsFlags: "CreateProcess and retry with windows_detach_flags_without_breakaway(), " "matching gateway_windows._spawn_detached's fallback pattern." ) + + def test_watcher_rewrites_console_python_to_windowless(self): + """The post-update respawn must NOT relaunch the gateway with the + venv's console ``python.exe``. + + Regression for the "terminal window stays open permanently after a + GUI update" report: ``_gateway_run_args_for_profile`` builds the + respawn argv from ``get_python_path()`` (console ``python.exe``). + On Windows, launching that interpreter — even under + CREATE_NO_WINDOW — leaves a persistent console window because uv's + venv launcher re-execs the base console interpreter. The watcher + must route the argv through + ``gateway_windows.windowless_gateway_restart_spec`` so it becomes + ``pythonw.exe`` with the cwd + PYTHONPATH overlay the base + interpreter needs. + + Static check: the watcher build (in ``_spawn_gateway_restart_watcher``) + must invoke the rewrite helper and thread the cwd / env overlay into + the inlined respawn ``Popen``. + """ + root = Path(__file__).resolve().parents[2] + text = (root / "hermes_cli" / "gateway.py").read_text(encoding="utf-8") + assert "windowless_gateway_restart_spec" in text, ( + "_spawn_gateway_restart_watcher must rewrite the respawn argv via " + "gateway_windows.windowless_gateway_restart_spec so the gateway " + "comes back as windowless pythonw.exe, not console python.exe." + ) + marker = "watcher = textwrap.dedent(" + idx = text.find(marker) + end = text.find(".strip()", idx) + block = text[idx:end] + # The inlined respawn must apply the cwd + env overlay the base + # interpreter needs — without them the windowless pythonw can't + # import hermes_cli. + assert '_popen_kwargs["cwd"]' in block, ( + "Inlined respawn must set cwd from the windowless spec so the " + "base interpreter starts in the stable gateway working dir." + ) + assert '_popen_kwargs["env"]' in block, ( + "Inlined respawn must overlay env (VIRTUAL_ENV / PYTHONPATH / " + "HERMES_HOME) so the windowless base pythonw resolves hermes_cli." + ) + + +class TestWindowlessGatewayRestartSpec: + """gateway_windows.windowless_gateway_restart_spec — the helper that + converts a console-python gateway argv into a windowless pythonw one.""" + + def test_noop_on_non_windows(self): + import hermes_cli.gateway_windows as gw + + argv = ["/path/venv/bin/python", "-m", "hermes_cli.main", "gateway", "run"] + with mock.patch.object(gw.sys, "platform", "linux"): + new_argv, cwd, env = gw.windowless_gateway_restart_spec(list(argv)) + assert new_argv == argv + assert cwd == "" + assert env == {} + + def test_empty_argv_is_safe(self): + import hermes_cli.gateway_windows as gw + + new_argv, cwd, env = gw.windowless_gateway_restart_spec([]) + assert new_argv == [] + assert cwd == "" + assert env == {} + + def test_windows_rewrites_to_pythonw_and_preserves_tail(self): + """On Windows the interpreter is swapped for its windowless sibling + while every subsequent argument is preserved verbatim.""" + import hermes_cli.gateway_windows as gw + + # Pre-import on the (Linux) host so the function's lazy + # ``from hermes_cli.gateway import PROJECT_ROOT`` resolves from + # sys.modules instead of re-importing under the win32 platform + # patch below — a fresh import would run gateway/status.py's + # ``if sys.platform == "win32": import msvcrt`` branch and crash on + # Linux CI with ModuleNotFoundError. + import hermes_cli.config # noqa: F401 + import hermes_cli.gateway # noqa: F401 + + argv = [ + "C:/venv/Scripts/python.exe", + "-m", + "hermes_cli.main", + "--profile", + "work", + "gateway", + "run", + "--replace", + ] + + def fake_resolve(python_exe): + return ("C:/base/pythonw.exe", Path("C:/venv"), ["C:/venv/Lib/site-packages"]) + + # Mock get_hermes_home too: the real one calls Path.resolve(), which + # consults sysconfig and raises ModuleNotFoundError under the win32 + # platform patch on a Linux host. + with mock.patch.object(gw.sys, "platform", "win32"), mock.patch.object( + gw, "_resolve_detached_python", side_effect=fake_resolve + ), mock.patch.object( + gw, "_stable_gateway_working_dir", return_value="C:/hermes" + ), mock.patch( + "hermes_cli.config.get_hermes_home", return_value="C:/hermes" + ): + new_argv, cwd, env = gw.windowless_gateway_restart_spec(list(argv)) + + assert new_argv[0] == "C:/base/pythonw.exe" + # Everything after the interpreter is byte-for-byte preserved. + assert new_argv[1:] == argv[1:] + assert cwd == "C:/hermes" + assert env["VIRTUAL_ENV"] == str(Path("C:/venv")) + assert "PYTHONPATH" in env + assert "site-packages" in env["PYTHONPATH"]