fix(gateway,windows): respawn gateway windowless after GUI update (#52239)

The post-update gateway restart path relaunched the gateway with the
venv's console `python.exe` (via `get_python_path()` in
`_gateway_run_args_for_profile`). On Windows this leaves a terminal
window open permanently: uv's `venv\Scripts\python.exe` is a launcher
shim that re-execs the *base* console interpreter, which allocates its
own conhost — and `CREATE_NO_WINDOW` cannot suppress that second window.
The clean-start path (`_spawn_detached`) already dodges this by routing
through `_resolve_detached_python` to use the windowless base
`pythonw.exe`; the restart watcher did not.

Symptom (reported on Windows 11): after an in-app GUI update, a console
window for the gateway stays open and never closes. Confirmed on the
reporter's box — the running gateway was `python.exe ... gateway run
--replace` with a live conhost child and the foreground "Press Ctrl+C to
stop" banner, born exactly at the update's "Restarting Windows gateway"
log line.

Fix:
- Add `gateway_windows.windowless_gateway_restart_spec(run_argv)` which
  rewrites a console-python gateway argv into the windowless `pythonw.exe`
  equivalent and returns the cwd + env overlay (VIRTUAL_ENV / PYTHONPATH /
  HERMES_HOME) the base interpreter needs to import `hermes_cli` without
  the venv launcher's site config. No-op on POSIX.
- `_spawn_gateway_restart_watcher` now applies that rewrite on Windows and
  threads cwd= / env= into the inlined respawn Popen. Covers both restart
  entry points (`launch_detached_profile_gateway_restart` and
  `launch_detached_gateway_restart_by_cmdline`). CREATE_NO_WINDOW |
  DETACHED_PROCESS | CREATE_BREAKAWAY_FROM_JOB and the breakaway-denied
  fallback are all preserved.

Verified E2E on a real Windows 11 box: drove the actual watcher against a
dummy old-pid; the respawned gateway came up as `pythonw.exe` (zero
console python, no conhost child) and booted fully (housekeeping + kanban
dispatcher started → imports resolved under the base interpreter).

Tests: TestWindowlessGatewayRestartSpec (behavior) +
TestGatewayDetachedWatcherWindowsFlags regression assert. Pre-existing
Linux-only failures on a Windows host (SIGKILL, systemd, docker-root)
confirmed identical on the bare base.
This commit is contained in:
Teknium 2026-06-26 10:39:46 -07:00 committed by GitHub
parent bb6a4d2a57
commit d430684d7c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 234 additions and 3 deletions

View file

@ -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,

View file

@ -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.

View file

@ -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"]