mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
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:
parent
bb6a4d2a57
commit
d430684d7c
3 changed files with 234 additions and 3 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue