Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 49 additions & 3 deletions hermes_cli/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""

import asyncio
import json
import logging
import os
import shlex
Expand Down Expand Up @@ -717,6 +718,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
Expand All @@ -726,6 +759,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
Expand All @@ -742,10 +777,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":
_CREATE_NEW_PROCESS_GROUP = 0x00000200
_DETACHED_PROCESS = 0x00000008
Expand All @@ -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,
Expand Down
71 changes: 71 additions & 0 deletions hermes_cli/gateway_windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,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.

Expand Down
114 changes: 114 additions & 0 deletions tests/tools/test_windows_native_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import signal
import sys
from pathlib import Path
from unittest import mock
from unittest.mock import MagicMock

import pytest
Expand Down Expand Up @@ -1007,3 +1008,116 @@ def test_launch_detached_profile_gateway_restart_outer_popen_has_access_denied_f
"CreateProcess and retry without the breakaway bit, 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"]
Loading