Compare commits

...

1 Commits

Author SHA1 Message Date
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
2 changed files with 227 additions and 2 deletions

View File

@@ -612,13 +612,54 @@ def find_profile_gateway_processes(
def _gateway_run_args_for_profile(profile: str) -> list[str]:
args = [get_python_path(), "-m", "hermes_cli.main"]
python_exe = get_python_path()
if is_windows():
# uv-created venv launchers are a trap here: ``venv\Scripts\pythonw.exe``
# starts hidden but then re-execs the *base* interpreter as a console
# ``python.exe`` — and that re-exec is a fresh CreateProcess that does
# NOT inherit our CREATE_NO_WINDOW flag, so a blank console window pops
# up. That's exactly what users hit when the gateway is respawned after
# a Desktop-GUI ``hermes update``. Resolve the base ``pythonw.exe``
# directly — the same path ``_spawn_detached`` / ``_build_gateway_argv``
# take for ``hermes gateway start`` — so the post-update respawn is
# windowless. The matching VIRTUAL_ENV / PYTHONPATH overlay is applied
# to the spawn env in ``launch_detached_profile_gateway_restart`` so
# imports still resolve without the venv launcher shim.
from hermes_cli.gateway_windows import _resolve_detached_python
python_exe, _venv_dir, _extra_pythonpath = _resolve_detached_python(python_exe)
args = [python_exe, "-m", "hermes_cli.main"]
if profile != "default":
args.extend(["--profile", profile])
args.extend(["gateway", "run", "--replace"])
return args
def _gateway_respawn_env(spawn_env: dict[str, str]) -> dict[str, str]:
"""Overlay VIRTUAL_ENV / PYTHONPATH so a base-``pythonw.exe`` respawn can
import ``hermes_cli`` without the venv launcher shim.
Returns ``spawn_env`` unchanged on non-Windows so the POSIX spawn path is
byte-for-byte identical to the pre-fix behaviour (it inherits ``os.environ``
exactly as before). On Windows it mirrors what ``_build_gateway_argv`` does
for ``hermes gateway start``: point VIRTUAL_ENV at the venv and prepend the
repo root plus base-interpreter site-packages to PYTHONPATH.
"""
if not is_windows():
return spawn_env
from hermes_cli.gateway_windows import (
_prepend_pythonpath,
_resolve_detached_python,
)
_python, venv_dir, extra_pythonpath = _resolve_detached_python(get_python_path())
spawn_env["VIRTUAL_ENV"] = str(venv_dir)
spawn_env["PYTHONIOENCODING"] = "utf-8"
spawn_env["HERMES_GATEWAY_DETACHED"] = "1"
_prepend_pythonpath(spawn_env, [str(PROJECT_ROOT), *extra_pythonpath])
return spawn_env
def launch_detached_profile_gateway_restart(profile: str, old_pid: int) -> bool:
"""Relaunch a manually-run profile gateway after its current PID exits."""
if old_pid <= 0:
@@ -703,14 +744,33 @@ def launch_detached_profile_gateway_restart(profile: str, old_pid: int) -> bool:
"""
).strip()
# Resolve the watcher interpreter to the windowless base ``pythonw.exe``
# on Windows for the same reason as the respawned gateway (see
# ``_gateway_run_args_for_profile``): ``sys.executable`` during a
# GUI-driven ``hermes update`` is a console ``python.exe`` whose uv venv
# launcher re-execs a visible console. ``_resolve_detached_python`` is a
# no-op shape on non-Windows callers because we only consult it under the
# ``is_windows()`` guard below.
watcher_python = sys.executable
if is_windows():
from hermes_cli.gateway_windows import _resolve_detached_python
watcher_python, _wv, _wpp = _resolve_detached_python(sys.executable)
watcher_argv = [
sys.executable,
watcher_python,
"-c",
watcher,
str(old_pid),
*_gateway_run_args_for_profile(profile),
]
# The watcher inherits this env and the respawned gateway inherits it from
# the watcher, so the base-``pythonw.exe`` legs can import ``hermes_cli``
# without the venv launcher shim. No-op on POSIX (returns os.environ copy
# unchanged), preserving the pre-fix spawn behaviour bit-for-bit there.
spawn_env = _gateway_respawn_env(dict(os.environ))
# Same platform-aware detach for the watcher process itself — so
# closing the user's terminal doesn't kill the watcher.
try:
@@ -718,6 +778,7 @@ def launch_detached_profile_gateway_restart(profile: str, old_pid: int) -> bool:
watcher_argv,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=spawn_env,
**windows_detach_popen_kwargs(),
)
except OSError:
@@ -736,6 +797,7 @@ def launch_detached_profile_gateway_restart(profile: str, old_pid: int) -> bool:
watcher_argv,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=spawn_env,
**fallback_kwargs,
)
except OSError:

View File

@@ -0,0 +1,163 @@
"""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")