Files
hermes-agent/tests/hermes_cli/test_gateway_update_respawn_pythonw.py
teknium1 76fa55240d fix(gateway): use base pythonw for post-update Windows respawn
The post-update respawn watcher (launch_detached_profile_gateway_restart)
respawned the gateway via `_gateway_run_args_for_profile`, which used
`get_python_path()` — the console `python.exe`. On uv-created venvs even
`venv\Scripts\pythonw.exe` re-execs the base interpreter as a console
`python.exe`, and that re-exec is a fresh CreateProcess that does NOT
inherit the watcher's CREATE_NO_WINDOW flag. Result: a blank console
window pops up after every Desktop-GUI `hermes update`, and the respawned
gateway is tied to it.

`hermes gateway start` already avoids this by routing through
`_resolve_detached_python` to get the base `pythonw.exe` plus a
VIRTUAL_ENV / PYTHONPATH overlay (see `_build_gateway_argv`/`_spawn_detached`).
The post-update respawn path was the one launch site that never got the
same treatment — a sibling call path of the same bug class.

Fix:
- `_gateway_run_args_for_profile` now resolves the base `pythonw.exe` on
  Windows via `_resolve_detached_python` (no-op on POSIX — argv keeps
  `get_python_path()` verbatim).
- New `_gateway_respawn_env` overlays VIRTUAL_ENV / PYTHONPATH /
  HERMES_GATEWAY_DETACHED so the base-interpreter respawn can import
  `hermes_cli` without the venv launcher shim. Returns the env unchanged
  on POSIX, preserving pre-fix spawn behaviour bit-for-bit.
- The watcher interpreter itself is resolved the same way and both
  watcher Popen calls pass the overlay env; the respawned gateway
  inherits it transitively from the watcher.

This complements #41028 / #38605, which fix the same uv-pythonw console
trap in the Scheduled-Task `.cmd` wrapper (`_build_gateway_cmd_script`,
the login path). Neither touches the post-update respawn argv this PR
fixes.

Adds tests/hermes_cli/test_gateway_update_respawn_pythonw.py:
base-pythonw resolution for uv venvs, the env overlay, a POSIX-equivalence
guard, and a live-Windows windowless-interpreter check.
2026-06-11 06:32:25 -07:00

164 lines
6.6 KiB
Python

"""Regression tests for the post-update gateway respawn interpreter on Windows.
Background
----------
When the Desktop GUI runs ``hermes update``, it spawns the post-update
respawn watcher via
``hermes_cli.gateway.launch_detached_profile_gateway_restart``. That watcher
polls the old gateway PID and, once it exits, respawns the gateway using the
argv built by ``_gateway_run_args_for_profile``.
The bug: that argv used ``get_python_path()`` — the *console* ``python.exe``.
For uv-created venvs, even ``venv\\Scripts\\pythonw.exe`` re-execs the base
interpreter as a console ``python.exe`` (the re-exec is a fresh CreateProcess
that does NOT inherit ``CREATE_NO_WINDOW``), so a blank console window pops up
after every GUI-driven update. ``hermes gateway start`` avoided this by going
through ``_resolve_detached_python`` to get the *base* ``pythonw.exe`` plus a
``VIRTUAL_ENV`` / ``PYTHONPATH`` overlay; the post-update respawn path never
got the same treatment.
These tests lock in:
* Windows respawn argv resolves to the base ``pythonw.exe`` (not the venv
``Scripts`` shim, not console ``python.exe``).
* The respawn env carries the matching ``VIRTUAL_ENV`` / ``PYTHONPATH`` so a
base-interpreter respawn can still import ``hermes_cli``.
* POSIX behaviour is byte-for-byte unchanged (argv keeps ``sys.executable``'s
resolved path; env overlay is a no-op).
"""
import sys
from pathlib import Path
import pytest
import hermes_cli.gateway as gateway
import hermes_cli.gateway_windows as gateway_windows
def _make_uv_venv(tmp_path: Path) -> dict[str, Path]:
"""Fabricate a uv-style venv layout: venv Scripts python(w).exe + a base
interpreter referenced by pyvenv.cfg's ``home`` with its own pythonw.exe."""
project = tmp_path / "project"
scripts = project / "venv" / "Scripts"
site_packages = project / "venv" / "Lib" / "site-packages"
base = tmp_path / "uv" / "python" / "cpython-3.11-windows-x86_64-none"
for directory in (scripts, site_packages, base):
directory.mkdir(parents=True, exist_ok=True)
venv_python = scripts / "python.exe"
venv_pythonw = scripts / "pythonw.exe"
base_pythonw = base / "pythonw.exe"
for exe in (venv_python, venv_pythonw, base_pythonw):
exe.write_text("", encoding="utf-8")
(project / "venv" / "pyvenv.cfg").write_text(
f"home = {base}\nimplementation = CPython\nuv = 0.11.14\nversion_info = 3.11.15\n",
encoding="utf-8",
)
return {
"project": project,
"venv_python": venv_python,
"venv_pythonw": venv_pythonw,
"base_pythonw": base_pythonw,
"site_packages": site_packages,
}
class TestRespawnArgvUsesBasePythonw:
def test_windows_uv_venv_resolves_base_pythonw(self, tmp_path, monkeypatch):
"""The respawn argv must use the base pythonw.exe for a uv venv,
never the venv Scripts shim or console python.exe."""
layout = _make_uv_venv(tmp_path)
monkeypatch.setattr(gateway, "is_windows", lambda: True)
monkeypatch.setattr(gateway, "get_python_path", lambda: str(layout["venv_python"]))
argv = gateway._gateway_run_args_for_profile("default")
assert argv[0] == str(layout["base_pythonw"])
assert argv[0] != str(layout["venv_python"])
assert argv[0] != str(layout["venv_pythonw"])
assert argv[1:] == ["-m", "hermes_cli.main", "gateway", "run", "--replace"]
def test_windows_non_default_profile_keeps_profile_arg(self, tmp_path, monkeypatch):
layout = _make_uv_venv(tmp_path)
monkeypatch.setattr(gateway, "is_windows", lambda: True)
monkeypatch.setattr(gateway, "get_python_path", lambda: str(layout["venv_python"]))
argv = gateway._gateway_run_args_for_profile("work")
assert argv[0] == str(layout["base_pythonw"])
assert argv[1:] == [
"-m",
"hermes_cli.main",
"--profile",
"work",
"gateway",
"run",
"--replace",
]
class TestRespawnEnvOverlay:
def test_windows_overlay_sets_virtualenv_and_pythonpath(self, tmp_path, monkeypatch):
"""A base-pythonw respawn needs VIRTUAL_ENV + PYTHONPATH so imports
resolve without the venv launcher shim."""
layout = _make_uv_venv(tmp_path)
monkeypatch.setattr(gateway, "is_windows", lambda: True)
monkeypatch.setattr(gateway, "get_python_path", lambda: str(layout["venv_python"]))
env = gateway._gateway_respawn_env({})
assert env["VIRTUAL_ENV"] == str(layout["project"] / "venv")
assert env["HERMES_GATEWAY_DETACHED"] == "1"
assert "PYTHONPATH" in env
# Repo root and the base-interpreter site-packages must both be on it.
assert str(gateway.PROJECT_ROOT) in env["PYTHONPATH"]
assert str(layout["site_packages"]) in env["PYTHONPATH"]
def test_windows_overlay_prepends_to_existing_pythonpath(self, tmp_path, monkeypatch):
layout = _make_uv_venv(tmp_path)
monkeypatch.setattr(gateway, "is_windows", lambda: True)
monkeypatch.setattr(gateway, "get_python_path", lambda: str(layout["venv_python"]))
monkeypatch.setenv("PYTHONPATH", "/preexisting/entry")
env = gateway._gateway_respawn_env({})
assert env["PYTHONPATH"].endswith("/preexisting/entry")
assert str(gateway.PROJECT_ROOT) in env["PYTHONPATH"]
class TestPosixUnchanged:
"""POSIX must be byte-for-byte identical to the pre-fix behaviour."""
def test_posix_argv_uses_get_python_path_verbatim(self, monkeypatch):
monkeypatch.setattr(gateway, "is_windows", lambda: False)
monkeypatch.setattr(gateway, "get_python_path", lambda: "/usr/bin/python3")
argv = gateway._gateway_run_args_for_profile("default")
assert argv == [
"/usr/bin/python3",
"-m",
"hermes_cli.main",
"gateway",
"run",
"--replace",
]
def test_posix_env_overlay_is_noop(self, monkeypatch):
monkeypatch.setattr(gateway, "is_windows", lambda: False)
original = {"PATH": "/usr/bin", "FOO": "bar"}
result = gateway._gateway_respawn_env(dict(original))
assert result == original
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific regression")
class TestLiveWindowsRespawn:
"""On a real Windows host the resolver should pick a *windowed* interpreter
(pythonw) for the running gateway's own venv, with no console flag needed."""
def test_resolved_interpreter_is_windowless(self):
argv = gateway._gateway_run_args_for_profile("default")
assert argv[0].lower().endswith("pythonw.exe")